短剧视频随机合并去除无声片段

短剧创作往往需要从大量素材中筛选、组合与优化,手工处理不仅耗时,也容易造成命名混乱和节奏拖沓。通过自动化脚本的方式,可以让视频在批量化处理下更高效地产生紧凑成片。

本文展示了完整的短剧生成流程,从素材重命名、随机拼接到静音剔除的环节实现,结合 Python 与 ffmpeg 工具链,构建出一套高效可扩展的自动化方案。

文章目录

应用场景

在进行短剧剪辑时候可能会获取若干个视频,我们要做的事情是将这些视频按照顺序随机几个合并在一起生成新的短剧并去掉视频中的无声片段构建成新的短剧,让剧情更加紧凑。

rename.py

这段代码的作用是针对短剧剪辑过程中获取到的原始视频素材进行统一的命名处理,以便后续在进行批量合并和无声片段剔除时能够保持素材编号的一致性和可控性。逻辑上,它遍历指定的根目录,自动寻找所有以 .mp4 结尾的视频文件,并通过正则表达式提取文件名中的数字部分作为新的文件名。如果文件名中存在数字,就将该数字作为唯一标识重命名为"数字+.mp4"的形式,这样能够让原本可能带有各种冗余符号、不同来源格式的文件在重命名后保持整齐的顺序化管理。

这种做法能够有效避免由于素材文件名过长或包含特殊符号而带来的合并出错问题,同时也便于在后续的处理环节,例如按编号顺序随机选取若干视频进行拼接,或者在剔除静音片段后重建紧凑剧情时,确保文件之间具备一致的编号逻辑。代码中还内置了防冲突机制,如果目标名称已存在则跳过该文件,避免覆盖已重命名的素材。整体来看,该脚本承担了数据清理和预处理的角色,让后续的短剧合成环节能够更高效、更可控地执行。

python 复制代码
import os
import re

# 根目录路径
base_dir = r"short_video"


def rename_mp4():
    for root, dirs, files in os.walk(base_dir):
        for file in files:
            if file.lower().endswith(".mp4"):
                # 提取文件名中的第一个数字串
                match = re.search(r"\d+", file)
                if match:
                    new_name = match.group(0) + ".mp4"
                    old_path = os.path.join(root, file)
                    new_path = os.path.join(root, new_name)

                    if old_path != new_path:
                        if not os.path.exists(new_path):
                            os.rename(old_path, new_path)
                            print(f"重命名: {file} -> {new_name}")
                        else:
                            print(f"跳过(已存在): {new_name}")


rename_mp4()

group_video.py

这段代码承担的是在短剧制作过程中将已经按编号整理好的视频片段,按照一定的随机规则重新组合成新的短剧段落。整体的逻辑以文件夹为基本单位,从指定的素材目录中读取视频,并在每个子文件夹下生成若干个拼接结果。这样的处理能够让同一批素材通过不同的组合方式生成多版本短剧,既能增加成片的丰富性,也能保证剧情的紧凑度和多样性。

在功能实现上,程序首先会通过数字编号来确保视频文件的顺序一致性,避免因文件名不规则导致的混乱。每次拼接任务都会随机决定所选视频的数量,并且在素材范围内随机挑选起始位置,以保证生成结果具有差异化。通过这种方式,一个文件夹中的素材就能衍生出多个不同的组合版本,为短剧编辑提供了灵活的创作空间。

拼接部分调用了 ffmpeg 的 concat 模式,采用无损流拷贝的方式直接合并视频而不重新编码,既提升了执行效率,也避免了画质损失,同时启用了 +faststart 参数来优化网络播放体验。为了保证过程的清洁,代码在执行完拼接命令后会删除临时生成的列表文件,避免留下冗余数据。输出结果统一放置在名为 group_video 的目录下,并根据起始编号和拼接数量来命名,清晰地标识了每个组合包含的片段范围。

python 复制代码
import os
import random
import subprocess
from collections import defaultdict

# 配置参数
INPUT_DIR = "./short_video"  # 视频根目录
MIN_VIDEOS = 3  # 每组最少视频数
MAX_VIDEOS = 5  # 每组最多视频数
NUM_GROUPS = 4  # 每个子文件夹生成几个拼接视频


def get_video_files(folder):
    """获取文件夹中按数字排序的视频文件"""
    videos = []
    for f in os.listdir(folder):
        if f.lower().endswith('.mp4') and f[:-4].isdigit():
            videos.append((int(f[:-4]), os.path.join(folder, f)))
    return [v[1] for v in sorted(videos, key=lambda x: x[0])]


def concatenate_videos(video_list, output_path):
    """使用ffmpeg拼接视频"""
    # 生成临时文件列表
    list_file = os.path.join(os.path.dirname(output_path), "concat_list.txt")
    with open(list_file, 'w', encoding='utf-8') as f:
        for video in video_list:
            f.write(f"file '{os.path.abspath(video)}'\n")

    # 执行拼接命令
    cmd = [
        'ffmpeg',
        '-y',  # 覆盖输出文件
        '-f', 'concat',  # 使用拼接模式
        '-safe', '0',  # 允许任意文件路径
        '-i', list_file,  # 输入文件列表
        '-c', 'copy',  # 直接流拷贝(不重新编码)
        '-movflags', '+faststart',  # 优化网络播放
        output_path
    ]

    try:
        subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    except subprocess.CalledProcessError as e:
        print(f"拼接失败: {e.stderr.decode('utf-8')}")
    finally:
        # 删除临时文件
        if os.path.exists(list_file):
            os.remove(list_file)


def process_folder(folder_path):
    """处理单个文件夹"""
    videos = get_video_files(folder_path)
    if not videos:
        print(f"{folder_path} 中没有找到视频文件")
        return

    # 创建输出目录
    output_dir = os.path.join(folder_path, "group_video")
    os.makedirs(output_dir, exist_ok=True)

    # 生成指定数量的拼接视频
    for i in range(1, NUM_GROUPS + 1):
        # 随机决定本次拼接的视频数量
        num_videos = random.randint(MIN_VIDEOS, min(MAX_VIDEOS, len(videos)))

        # 随机选择起始位置,确保有足够视频
        max_start = len(videos) - num_videos
        if max_start < 0:
            continue

        start_idx = random.randint(0, max_start)
        selected_videos = videos[start_idx:start_idx + num_videos]

        # 生成输出文件名
        output_name = f"group_{i}_{start_idx + 1}-{start_idx + num_videos}.mp4"
        output_path = os.path.join(output_dir, output_name)

        print(f"正在生成: {output_name} (包含 {num_videos} 个视频)")
        concatenate_videos(selected_videos, output_path)


def main():
    # 遍历根目录下的所有子文件夹
    for root, dirs, files in os.walk(INPUT_DIR):
        for dir_name in dirs:
            if dir_name == "group_video":  # 跳过输出目录
                continue

            folder_path = os.path.join(root, dir_name)
            print(f"\n处理文件夹: {folder_path}")
            process_folder(folder_path)


if __name__ == "__main__":
    main()

process.py

这份脚本承担的是整个短剧生成流水线中最核心的"去掉无声片段、保留有效语音"这一环节。它在业务场景中的定位非常明确:前面两个脚本分别完成了素材重命名和随机拼接,这个阶段则通过语音识别技术,自动分析拼接后的视频片段,精准提取出包含语音的部分并重新构建新的视频,使剧情更加紧凑。

整体逻辑由几个关键步骤组成。程序会逐个扫描之前 group_video 目录下生成的拼接视频,先调用 ffmpeg 提取音频并保存为单声道、16k 采样率的 WAV 文件,再交给 FunASRAutoModel 模型进行语音识别与语音活动检测。模型返回的结果包含每一句话的时间戳,代码利用这些时间戳加上预留的前后时长来确定有效语音片段。对过短的片段直接丢弃,确保内容具备可观看性,同时对间隔过小的片段进行合并,以避免画面过于碎片化。

在分段剪辑的实现上,程序通过多线程加速 ffmpeg 的片段裁剪操作,将每一段语音区间剪切成独立文件,随后利用 ffmpeg concat 无损拼接,重构出一条只包含语音的紧凑视频。这样既保留了剧情对话和有效声音,又清除了冗余的无声画面,提升了短剧节奏感。整个过程中的临时音频与视频片段会在执行结束后自动清理,保证结果目录只留下最终的"result_video"。

python 复制代码
import os
import tempfile
import subprocess
from concurrent.futures import ThreadPoolExecutor
from typing import List, Tuple
import time

# ========== 配置参数 ==========
INPUT_ROOT = r"./short_video"  # 根目录
MAX_WORKERS = 4  # 最大线程数
DEVICE = "cuda"  # if torch.cuda.is_available() else "cpu"

# 模型配置
MODEL_ID = "iic/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch"

# 处理参数
PAD_PRE = 0.15  # 段首保留时间
PAD_POST = 0.20  # 段尾保留时间
MIN_DUR = 0.60  # 最小片段时长
MERGE_GAP = 0.30  # 合并间隔


# ========== 工具函数 ==========
def run_command(cmd: list) -> bool:
    """执行命令并返回是否成功"""
    result = subprocess.run(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        universal_newlines=True
    )
    if result.returncode != 0:
        print(f"命令执行失败: {' '.join(cmd)}")
        print(result.stderr)
    return result.returncode == 0


def get_video_duration(video_path: str) -> float:
    """获取视频时长(秒)"""
    result = subprocess.run([
        "ffprobe", "-v", "error",
        "-show_entries", "format=duration",
        "-of", "default=noprint_wrappers=1:nokey=1",
        video_path
    ], capture_output=True, text=True)
    return float(result.stdout.strip()) if result.returncode == 0 else 0


def extract_audio(video_path: str, audio_path: str) -> bool:
    """从视频提取音频"""
    return run_command([
        "ffmpeg", "-y", "-i", video_path,
        "-vn", "-ac", "1", "-ar", "16000",
        "-f", "wav", audio_path,
        "-loglevel", "error"
    ])


def cut_video_segment(input_path: str, output_path: str, start: float, end: float) -> bool:
    """剪切单个视频片段"""
    return run_command([
        "ffmpeg", "-y",
        "-ss", str(start), "-to", str(end), "-i", input_path,
        "-c:v", "libx264", "-preset", "fast", "-crf", "18",
        "-c:a", "aac", "-movflags", "+faststart",
        "-loglevel", "error", output_path
    ])


def concat_videos(segments: List[str], output_path: str) -> bool:
    """拼接多个视频片段"""
    with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
        list_file = f.name
        for seg in segments:
            f.write(f"file '{os.path.abspath(seg)}'\n")

    success = run_command([
        "ffmpeg", "-y", "-f", "concat", "-safe", "0",
        "-i", list_file, "-c", "copy",
        "-movflags", "+faststart", "-loglevel", "error",
        output_path
    ])

    os.remove(list_file)
    return success


# ========== 主处理逻辑 ==========
def process_video_segments(input_path: str, segments: List[Tuple[float, float]], output_path: str):
    """多线程处理视频片段"""
    if os.path.exists(output_path):
        print(f"已存在跳过: {output_path}")
        return True

    temp_dir = tempfile.mkdtemp(prefix="video_segments_")
    segment_files = []

    try:
        # 多线程剪切片段
        with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
            futures = []
            for i, (start, end) in enumerate(segments, 1):
                seg_path = os.path.join(temp_dir, f"seg_{i:04d}.mp4")
                segment_files.append(seg_path)
                futures.append(executor.submit(
                    cut_video_segment,
                    input_path, seg_path, start, end
                ))

            # 等待所有任务完成
            for i, future in enumerate(futures, 1):
                if not future.result():
                    print(f"片段{i}剪切失败")
                    return False

        # 拼接片段
        if not concat_videos(segment_files, output_path):
            return False

        return True

    finally:
        # 清理临时文件
        for seg_file in segment_files:
            try:
                os.remove(seg_file)
            except:
                pass
        try:
            os.rmdir(temp_dir)
        except:
            pass


def process_group_video(video_path: str, output_dir: str, model):
    """处理单个视频文件"""
    output_name = f"cut_{os.path.basename(video_path)}"
    output_path = os.path.join(output_dir, output_name)

    if os.path.exists(output_path):
        print(f"✓ 已存在跳过: {output_name}")
        return True

    print(f"▶ 开始处理: {os.path.basename(video_path)}")
    start_time = time.time()

    temp_audio = os.path.join(output_dir, f"temp_{os.path.basename(video_path)}.wav")

    try:
        # 1. 提取音频
        if not extract_audio(video_path, temp_audio):
            return False

        # 2. 语音识别
        result = model.generate(
            input=temp_audio,
            batch_size_s=30,
            sentence_timestamp=True,
            param_dict={"pred_timestamp": True}
        )

        # 3. 解析时间段
        duration = get_video_duration(video_path)
        segments = []
        for seg in result[0]["sentence_info"]:
            start = max(0, seg["start"] / 1000 - PAD_PRE)
            end = min(duration, seg["end"] / 1000 + PAD_POST)
            if end - start >= MIN_DUR:
                segments.append((start, end))

        # 4. 合并相邻片段
        if not segments:
            print("⚠️ 无有效语音段")
            return False

        segments.sort()
        merged = [segments[0]]
        for seg in segments[1:]:
            if seg[0] - merged[-1][1] <= MERGE_GAP:
                merged[-1] = (merged[-1][0], seg[1])
            else:
                merged.append(seg)

        # 5. 处理视频
        if not process_video_segments(video_path, merged, output_path):
            return False

        elapsed = time.time() - start_time
        print(f"✓ 完成: {output_name} [{elapsed:.1f}s]")
        return True

    except Exception as e:
        print(f"❌ 处理失败: {str(e)}")
        return False
    finally:
        if os.path.exists(temp_audio):
            os.remove(temp_audio)


def main():
    try:
        from funasr import AutoModel
        import torch
    except ImportError:
        print("请安装依赖: pip install funasr torch")
        return

    # 加载模型
    print("初始化语音识别模型...")
    model = AutoModel(
        model=MODEL_ID,
        vad_model="fsmn-vad",
        punc_model="ct-punc",
        device=DEVICE
    )

    # 遍历目录结构
    for root, dirs, files in os.walk(INPUT_ROOT):
        if "group_video" not in dirs:
            continue

        group_dir = os.path.join(root, "group_video")
        result_dir = os.path.join(root, "result_video")
        os.makedirs(result_dir, exist_ok=True)

        print(f"\n📁 处理目录: {root}")

        # 处理group_video中的视频
        video_files = [
            f for f in os.listdir(group_dir)
            if f.lower().endswith(".mp4")
        ]

        for video_file in video_files:
            process_group_video(
                os.path.join(group_dir, video_file),
                result_dir,
                model
            )


if __name__ == "__main__":
    main()

最终生成的结果目录

总结

本项目通过三段脚本实现了短剧剪辑流水线的关键环节:统一命名保证素材管理清晰,随机拼接提升生成多样性,静音剔除让成片节奏更紧凑。自动化处理降低了人工操作成本,确保了短剧输出的高效与稳定。

未来可以在此基础上引入更多智能化功能,例如基于镜头识别的场景切分、基于语义分析的内容推荐,甚至结合大模型实现自动化剧情编排,使短剧生成更加智能化与个性化。