You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用FFmpeg拼接25fps视频后输出帧率异常为3.554fps的问题排查

问题:AWS Lambda中FFmpeg拼接视频后帧率异常、时长错误

环境与文件信息

  • 开发环境:Node.js 18 + AWS Lambda
  • FFmpeg版本:7(Lambda层静态构建,仅含ffmpeg无ffprobe)
  • 文件规格:
    • 音频:WAV格式,13kbps,AAC(LC)编码,时长6分28秒
    • 视频:MP4格式,1280x720分辨率,25fps,H.264编码,时长3分27秒

目标需求

  • 用空白视频填补原视频前后的时长间隙,让完整音频得到覆盖
  • 剥离原MP4的音频,仅使用指定WAV音频
  • 最终MP4全程播放WAV音频:前27秒为空白视频,接着播放原MP4,最后用空白视频补足到音频总时长6分28秒

实际问题

生成的MP4文件帧率变为3.554fps,时长错误显示为10分06秒

现有代码

import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { createWriteStream, createReadStream, promises as fsPromises } from 'fs';
import { exec } from 'child_process';
import { promisify } from 'util';
import { basename } from 'path';

const execAsync = promisify(exec);

const s3 = new S3Client({ region: 'us-east-1' });

async function downloadFileFromS3(bucket, key, downloadPath) {
    const getObjectParams = { Bucket: bucket, Key: key };
    const command = new GetObjectCommand(getObjectParams);
    const { Body } = await s3.send(command);
    return new Promise((resolve, reject) => {
        const fileStream = createWriteStream(downloadPath);
        Body.pipe(fileStream);
        Body.on('error', reject);
        fileStream.on('finish', resolve);
    });
}

async function uploadFileToS3(bucket, key, filePath) {
    const fileStream = createReadStream(filePath);
    const uploadParams = { Bucket: bucket, Key: key, Body: fileStream };
    try {
        await s3.send(new PutObjectCommand(uploadParams));
        console.log(`File uploaded successfully to ${bucket}/${key}`);
    } catch (err) {
        console.error("Error uploading file: ", err);
        throw new Error('Failed to upload file to S3');
    }
}

function parseDuration(durationStr) {
    const parts = durationStr.split(':');
    return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseFloat(parts[2]);
}

export async function handler(event) {
    const videoBucket = "video-interaction-content";
    const videoKey = event.videoKey;
    const audioBucket = "audio-call-recordings";
    const audioKey = event.audioKey;
    const outputBucket = "synched-audio-video";
    const outputKey = `combined_${basename(videoKey, '.mp4')}.mp4`;

    const audioStartSeconds = new Date(event.audioStart).getTime() / 1000;
    const videoStartSeconds = new Date(event.videoStart).getTime() / 1000;
    const audioDurationSeconds = event.audioDuration / 1000;
    const timeDifference = audioStartSeconds - videoStartSeconds;

    try {
        const videoPath = `/tmp/${basename(videoKey)}`;
        const audioPath = `/tmp/${basename(audioKey)}`;
        await downloadFileFromS3(videoBucket, videoKey, videoPath);
        await downloadFileFromS3(audioBucket, audioKey, audioPath);

        //Initialize file list with video
        let filelist = [`file '${videoPath}'`];
        let totalVideoDuration = 0; // Initialize total video duration

        // Create first blank video if needed
        if (timeDifference < 0) {
            const blankVideoDuration = Math.abs(timeDifference);
            const blankVideoPath = `/tmp/blank_video.mp4`;
            await execAsync(`/opt/bin/ffmpeg -f lavfi -i color=c=black:s=1280x720:r=25 -c:v libx264 -t ${blankVideoDuration} ${blankVideoPath}`);
            //Add first blank video first in file list
            filelist.unshift(`file '${blankVideoPath}'`);
            totalVideoDuration += blankVideoDuration;
            console.log(`First blank video created with duration: ${blankVideoDuration} seconds`);
        }
        
        const videoInfo = await execAsync(`/opt/bin/ffmpeg -i ${videoPath} -f null -`);
        const videoDurationMatch = videoInfo.stderr.match(/Duration: ([\d:.]+)/);
        const videoDuration = videoDurationMatch ? parseDuration(videoDurationMatch[1]) : 0;
        totalVideoDuration += videoDuration;

        // Calculate additional blank video duration
        const additionalBlankVideoDuration = audioDurationSeconds - totalVideoDuration;
        if (additionalBlankVideoDuration > 0) {
            const additionalBlankVideoPath = `/tmp/additional_blank_video.mp4`;
            await execAsync(`/opt/bin/ffmpeg -f lavfi -i color=c=black:s=1280x720:r=25 -c:v libx264 -t ${additionalBlankVideoDuration} ${additionalBlankVideoPath}`);
            //Add to the end of the file list
            filelist.push(`file '${additionalBlankVideoPath}'`);
            console.log(`Additional blank video created with duration: ${additionalBlankVideoDuration} seconds`);
        }

        // Create and write the file list to disk
        const concatFilePath = '/tmp/filelist.txt';
        await fsPromises.writeFile('/tmp/filelist.txt', filelist.join('\n'));

        const extendedVideoPath = `/tmp/extended_${basename(videoKey)}`;
        //await execAsync(`/opt/bin/ffmpeg -f concat -safe 0 -i /tmp/filelist.txt -c copy ${extendedVideoPath}`);
        
        // Use -vsync vfr to adjust frame timing without full re-encoding
        await execAsync(`/opt/bin/ffmpeg -f concat -safe 0 -i ${concatFilePath} -c copy -vsync vfr ${extendedVideoPath}`);

        const outputPath = `/tmp/output_${basename(videoKey, '.mp4')}.mp4`;
        //await execAsync(`/opt/bin/ffmpeg -i ${extendedVideoPath} -i ${audioPath} -map 0:v:0 -map 1:a:0 -c:v copy -c:a aac -b:a 192k -shortest ${outputPath}`);

        await execAsync(`/opt/bin/ffmpeg -i ${extendedVideoPath} -i ${audioPath} -map 0:v:0 -map 1:a:0 -c:v copy -c:a aac -b:a 192k -shortest -r 25 ${outputPath}`);
        console.log('Video and audio have been merged successfully');

        await uploadFileToS3(outputBucket, outputKey, outputPath);
        console.log('File upload complete.');

        return { statusCode: 200, body: JSON.stringify('Video and audio have been merged successfully.') };
    } catch (error) {
        console.error('Error in Lambda function:', error);
        return { statusCode: 500, body: JSON.stringify('Failed to process video and audio.') };
    }
}

已尝试方案

  • 尝试重新编码拼接后的文件,但Lambda函数超时
  • 尝试创建与原MP4参数一致(25fps)的空白视频,期望无需重新编码,但问题依旧
  • 尝试在FFmpeg命令中指定帧率25、添加/移除-vsync参数,未解决问题

解决建议

1. 修复视频拼接的时间基准与帧率问题

concat协议直接复制流时,不同视频的时间基准(timebase)、GOP结构不匹配会导致帧率和时长计算错误。可以:

  • 创建空白视频时强制匹配原视频的时间基准:
    # 先获取原视频的timebase
    videoTimebase=$(ffmpeg -i ${videoPath} 2>&1 | grep -oP 'timebase=\K[\d/]+')
    # 创建空白视频时指定参数
    ffmpeg -f lavfi -i color=c=black:s=1280x720:r=25 -c:v libx264 -t ${blankVideoDuration} -timebase ${videoTimebase} -g 25 ${blankVideoPath}
    
  • 放弃-c copy快速拼接,改用concat滤镜轻量编码,统一参数:
    ffmpeg -f concat -safe 0 -i ${concatFilePath} -c:v libx264 -r 25 -s 1280x720 -crf 23 -preset fast ${extendedVideoPath}
    
    -preset fast可平衡编码速度与文件大小,降低Lambda超时概率。

2. 修正音频合并的时长控制

去掉-shortest参数,强制视频时长匹配音频总时长:

# 先获取音频精确时长
audioDuration=$(ffmpeg -i ${audioPath} -show_entries format=duration -v quiet -of csv="p=0")
# 合并时指定时长
ffmpeg -i ${extendedVideoPath} -i ${audioPath} -map 0:v:0 -map 1:a:0 -c:v copy -c:a aac -b:a 192k -t ${audioDuration} -r 25 ${outputPath}

3. Lambda性能优化

  • 选择更大内存的Lambda实例:Lambda的CPU、网络带宽与内存正相关,内存越大编码速度越快,减少超时概率
  • 使用Graviton2优化的FFmpeg层:针对ARM架构优化FFmpeg,启用硬件加速编码
  • 清理临时文件:在每步操作后删除无用中间文件,避免/tmp空间不足导致错误

4. 提升时长解析准确性

替换原有的正则匹配方式,用FFmpeg直接输出时长数值:

async function getMediaDuration(filePath) {
  const { stderr } = await execAsync(`/opt/bin/ffmpeg -i ${filePath} -show_entries format=duration -v quiet -of csv="p=0"`);
  return parseFloat(stderr.trim());
}

内容的提问来源于stack exchange,提问作者Kendra Broom

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 03:07:34