【问题标题】:Is there an elegant way to split a file by chapter using ffmpeg?有没有一种优雅的方法可以使用 ffmpeg 按章节拆分文件?
【发布时间】:2015-07-30 02:52:48
【问题描述】:

this page 中,Albert Armea 使用ffmpeg 分享了按章节分割视频的代码。代码很简单,但不是很好看。

ffmpeg -i "$SOURCE.$EXT" 2>&1 |
grep Chapter |
sed -E "s/ *Chapter #([0-9]+\.[0-9]+): start ([0-9]+\.[0-9]+), end ([0-9]+\.[0-9]+)/-i \"$SOURCE.$EXT\" -vcodec copy -acodec copy -ss \2 -to \3 \"$SOURCE-\1.$EXT\"/" |
xargs -n 11 ffmpeg

有没有一种优雅的方式来完成这项工作?

【问题讨论】:

  • 我必须稍作修改才能使其正常工作,因为我的章节标题中有“章节”一词:| grep '^\s*Chapter' |
  • 我想知道如何做相反的事情:为每个文件添加章节标记的 concat 文件。
  • 看起来我们必须编写脚本。我们需要一个快捷方式来将 youtube .mkv 中带有章节的 vdeos 翻录成多个声音文件。

标签: ffmpeg


【解决方案1】:

(编辑:此提示来自 https://github.com/phiresky 通过此问题:https://github.com/harryjackson/ffmpeg_split/issues/2

您可以使用以下方式获取章节:

ffprobe -i fname -print_format json -show_chapters -loglevel error

如果我再写一次,我会使用 ffprobe 的 json 选项

(原答案如下)

这是一个工作 python 脚本。我在几个视频上对其进行了测试,效果很好。 Python 不是我的第一语言,但我注意到你使用它,所以我认为用 Python 编写它可能更有意义。我已将其添加到Github。如果您想改进,请提交拉取请求。

#!/usr/bin/env python
import os
import re
import subprocess as sp
from subprocess import *
from optparse import OptionParser

def parseChapters(filename):
  chapters = []
  command = [ "ffmpeg", '-i', filename]
  output = ""
  try:
    # ffmpeg requires an output file and so it errors 
    # when it does not get one so we need to capture stderr, 
    # not stdout.
    output = sp.check_output(command, stderr=sp.STDOUT, universal_newlines=True)
  except CalledProcessError, e:
    output = e.output 

  for line in iter(output.splitlines()):
    m = re.match(r".*Chapter #(\d+:\d+): start (\d+\.\d+), end (\d+\.\d+).*", line)
    num = 0 
    if m != None:
      chapters.append({ "name": m.group(1), "start": m.group(2), "end": m.group(3)})
      num += 1
  return chapters

def getChapters():
  parser = OptionParser(usage="usage: %prog [options] filename", version="%prog 1.0")
  parser.add_option("-f", "--file",dest="infile", help="Input File", metavar="FILE")
  (options, args) = parser.parse_args()
  if not options.infile:
    parser.error('Filename required')
  chapters = parseChapters(options.infile)
  fbase, fext = os.path.splitext(options.infile)
  for chap in chapters:
    print "start:" +  chap['start']
    chap['outfile'] = fbase + "-ch-"+ chap['name'] + fext
    chap['origfile'] = options.infile
    print chap['outfile']
  return chapters

def convertChapters(chapters):
  for chap in chapters:
    print "start:" +  chap['start']
    print chap
    command = [
        "ffmpeg", '-i', chap['origfile'],
        '-vcodec', 'copy',
        '-acodec', 'copy',
        '-ss', chap['start'],
        '-to', chap['end'],
        chap['outfile']]
    output = ""
    try:
      # ffmpeg requires an output file and so it errors 
      # when it does not get one
      output = sp.check_output(command, stderr=sp.STDOUT, universal_newlines=True)
    except CalledProcessError, e:
      output = e.output
      raise RuntimeError("command '{}' return with error (code {}): {}".format(e.cmd, e.returncode, e.output))

if __name__ == '__main__':
  chapters = getChapters()
  convertChapters(chapters)

【讨论】:

  • 这是另一个类似的 python 脚本,用于按章节解析 m4b 有声读物。 github.com/valekhz/m4b-converter
  • 我在下面发布了一个修改版本,它使用章节名称作为文件名。它并不优雅,但它有效:)
  • 还有第二个,刚才写的这个是为了AAX转MP3章节化转换github.com/OndrejSkalicka/aax-to-mp3-python
  • 确认:它确实有效,感谢您提供它!
  • 我所需要的很好的基础。我想按章节名称编辑一些内容,然后重新组合它们,但我可以看到如何轻松做到这一点。
【解决方案2】:
ffmpeg -i "$SOURCE.$EXT" 2>&1 \ # get metadata about file
| grep Chapter \ # search for Chapter in metadata and pass the results
| sed -E "s/ *Chapter #([0-9]+.[0-9]+): start ([0-9]+.[0-9]+), end ([0-9]+.[0-9]+)/-i \"$SOURCE.$EXT\" -vcodec copy -acodec copy -ss \2 -to \3 \"$SOURCE-\1.$EXT\"/" \ # filter the results, explicitly defining the timecode markers for each chapter
| xargs -n 11 ffmpeg # construct argument list with maximum of 11 arguments and execute ffmpeg

您的命令解析文件元数据并读出每一章的时间码标记。您可以为每一章手动执行此操作..

ffmpeg -i ORIGINALFILE.mp4 -acodec copy -vcodec copy -ss 0 -t 00:15:00 OUTFILE-1.mp4

或者您可以写出章节标记并使用这个更易于阅读的 bash 脚本运行它们。..

#!/bin/bash
# Author: http://crunchbang.org/forums/viewtopic.php?id=38748#p414992
# m4bronto

#     Chapter #0:0: start 0.000000, end 1290.013333
#       first   _     _     start    _     end

while [ $# -gt 0 ]; do

ffmpeg -i "$1" 2> tmp.txt

while read -r first _ _ start _ end; do
  if [[ $first = Chapter ]]; then
    read  # discard line with Metadata:
    read _ _ chapter

    ffmpeg -vsync 2 -i "$1" -ss "${start%?}" -to "$end" -vn -ar 44100 -ac 2 -ab 128  -f mp3 "$chapter.mp3" </dev/null

  fi
done <tmp.txt

rm tmp.txt

shift
done

或者你可以使用HandbrakeCLI,如this post中最初提到的那样,这个例子将第3章提取到3.mkv

HandBrakeCLI -c 3 -i originalfile.mkv -o 3.mkv

或者this post中提到了其他工具

mkvmerge -o output.mkv --split chapters:all input.mkv

【讨论】:

  • mkvmerge点赞。一个班轮获得所有章节,甚至适用于 windows ?
【解决方案3】:

原始 shell 代码的一个版本:

  • 通过提高效率
    • 使用ffprobe 代替ffmpeg
    • 分割输入而不是输出
  • 通过避免xargssed 提高了可靠性
  • 通过使用多行提高了可读性
  • 携带多个音频或字幕流
  • 从输出文件中删除章节(因为它们是无效的时间码)
  • 简化的命令行参数
#!/bin/sh -efu

input="$1"
ffprobe \
    -print_format csv \
    -show_chapters \
    "$input" |
cut -d ',' -f '5,7,8' |
while IFS=, read start end chapter
do
    ffmpeg \
        -nostdin \
        -ss "$start" -to "$end" \
        -i "$input" \
        -c copy \
        -map 0 \
        -map_chapters -1 \
        "${input%.*}-$chapter.${input##*.}"
done

为了防止它干扰循环,ffmpeg 被指示不要从 stdin 读取。

【讨论】:

  • 您可以使用-nostdin 代替&lt;/dev/null-c copy 代替-vcodec copy -acodec copy -scodec copy,以及-map 0 代替-map 0:a -map 0:v -map 0:s
  • 我还将-ss ...行移到-i ...行之前,否则ffmpeg构建输出文件是为了寻找而不是直接在输入中寻找。当您也在进行转码时,这会极大地加快速度。 根据您要拆分的内容,您可能不想这样做(我正在拆分和转码音频,因此可以搜索输入)。
  • @llogan @Scott 很棒的建议,谢谢!如果您手头有jq,我实际上会推荐@SebMa 的答案,该答案似乎基于我的,但由于使用ffprobe 的JSON 输出,未来的证据更多。但无论如何我都会采纳你的建议。
  • 这一个将除前一章信息之外的所有信息放在所有文件中,即。第一个为 1..23,第二个为 2..23,依此类推
【解决方案4】:

比使用带有jq 的JSON 提取数据更简单一点:

#!/usr/bin/env bash 
# For systems where "bash" in not in "/bin/"

set -efu

videoFile="$1"
ffprobe -hide_banner \
        "$videoFile" \
        -print_format json \
        -show_chapters \
        -loglevel error |
    jq -r '.chapters[] | [ .id, .start_time, .end_time | tostring ] | join(" ")' |
    while read chapter start end; do
        ffmpeg -nostdin \
               -ss "$start" -to "$end" \
               -i "$videoFile" \
               -map 0 \
               -map_chapters -1 \
               -c copy \
               -metadata title="$chapter"
               "${videoFile%.*}-$chapter.${videoFile##*.}";
    done

我使用tostring jq 函数,因为chapers[].id 是一个整数。

【讨论】:

    【解决方案5】:

    我修改了 Harry 的脚本以使用章节名称作为文件名。它以输入文件的名称(减去扩展名)输出到一个新目录。如果有同名的章节,它还会在每个章节名称前加上“1 - ”、“2 - ”等。

    #!/usr/bin/env python
    import os
    import re
    import pprint
    import sys
    import subprocess as sp
    from os.path import basename
    from subprocess import *
    from optparse import OptionParser
    
    def parseChapters(filename):
      chapters = []
      command = [ "ffmpeg", '-i', filename]
      output = ""
      m = None
      title = None
      chapter_match = None
      try:
        # ffmpeg requires an output file and so it errors
        # when it does not get one so we need to capture stderr,
        # not stdout.
        output = sp.check_output(command, stderr=sp.STDOUT, universal_newlines=True)
      except CalledProcessError, e:
        output = e.output
    
      num = 1
    
      for line in iter(output.splitlines()):
        x = re.match(r".*title.*: (.*)", line)
        print "x:"
        pprint.pprint(x)
    
        print "title:"
        pprint.pprint(title)
    
        if x == None:
          m1 = re.match(r".*Chapter #(\d+:\d+): start (\d+\.\d+), end (\d+\.\d+).*", line)
          title = None
        else:
          title = x.group(1)
    
        if m1 != None:
          chapter_match = m1
    
        print "chapter_match:"
        pprint.pprint(chapter_match)
    
        if title != None and chapter_match != None:
          m = chapter_match
          pprint.pprint(title)
        else:
          m = None
    
        if m != None:
          chapters.append({ "name": `num` + " - " + title, "start": m.group(2), "end": m.group(3)})
          num += 1
    
      return chapters
    
    def getChapters():
      parser = OptionParser(usage="usage: %prog [options] filename", version="%prog 1.0")
      parser.add_option("-f", "--file",dest="infile", help="Input File", metavar="FILE")
      (options, args) = parser.parse_args()
      if not options.infile:
        parser.error('Filename required')
      chapters = parseChapters(options.infile)
      fbase, fext = os.path.splitext(options.infile)
      path, file = os.path.split(options.infile)
      newdir, fext = os.path.splitext( basename(options.infile) )
    
      os.mkdir(path + "/" + newdir)
    
      for chap in chapters:
        chap['name'] = chap['name'].replace('/',':')
        chap['name'] = chap['name'].replace("'","\'")
        print "start:" +  chap['start']
        chap['outfile'] = path + "/" + newdir + "/" + re.sub("[^-a-zA-Z0-9_.():' ]+", '', chap['name']) + fext
        chap['origfile'] = options.infile
        print chap['outfile']
      return chapters
    
    def convertChapters(chapters):
      for chap in chapters:
        print "start:" +  chap['start']
        print chap
        command = [
            "ffmpeg", '-i', chap['origfile'],
            '-vcodec', 'copy',
            '-acodec', 'copy',
            '-ss', chap['start'],
            '-to', chap['end'],
            chap['outfile']]
        output = ""
        try:
          # ffmpeg requires an output file and so it errors
          # when it does not get one
          output = sp.check_output(command, stderr=sp.STDOUT, universal_newlines=True)
        except CalledProcessError, e:
          output = e.output
          raise RuntimeError("command '{}' return with error (code {}): {}".format(e.cmd, e.returncode, e.output))
    
    if __name__ == '__main__':
      chapters = getChapters()
      convertChapters(chapters)
    

    这需要花点时间才能弄清楚,因为我绝对不是 Python 人。这也很不优雅,因为它正在逐行处理元数据,因此需要跳过很多圈。 (即,标题和章节数据通过元数据输出在单独的循环中找到)

    但它确实有效,应该可以为您节省大量时间。它对我有用!

    【讨论】:

    • @JP。很高兴听到它!
    • 这在我独立运行ffmpeg -i 以确定文件元数据的格式后运行良好。我不得不修改正则表达式,因为我的章节不是Chapter #dd:dd 格式。尝试让你的正则表达式更健壮会很好:-)
    • 您确定路径的方式仅适用于对输入文件使用绝对路径时。否则变量path 为空,因此输出文件的路径是文档根目录内的目录,例如/test 用于输入文件test.mp4
    • 感谢@clifgriffin,我喜欢您的版本并对其进行了修改以在 Python 3 中工作。我还清理了导入并在章节号gist.github.com/showerbeer/97c1f31770572d05738cd2b74167f8a4 中添加了前导零@
    • 我将其保存为splitfilebychapter.sh。当我从命令行运行时,我发出splitfilebychapter.sh alargeaudiobook.mp3。它返回:splitfilebychapter.sh: error: Filename required。它是在寻找输入文件或输出文件的名称吗?
    【解决方案6】:

    我想要一些额外的东西,例如:

    • 提取封面
    • 使用章节名称作为文件名
    • 在文件名前加上前导零的计数器前缀,这样字母顺序在每个软件中都能正常工作
    • 制作播放列表
    • 修改元数据以包含章节名称
    • 根据元数据(年份作者-标题)将所有文件输出到新目录

    这是我的脚本(我使用了 Harry 的 ffprobe json 输出的提示)

    #!/bin/bash
    input="input.aax"
    EXT2="m4a"
    
    json=$(ffprobe -activation_bytes secret -i "$input" -loglevel error -print_format json -show_format -show_chapters)
    title=$(echo $json | jq -r ".format.tags.title")
    count=$(echo $json | jq ".chapters | length")
    target=$(echo $json | jq -r ".format.tags | .date + \" \" + .artist + \" - \" + .title")
    mkdir "$target"
    
    ffmpeg -activation_bytes secret -i $input -vframes 1 -f image2 "$target/cover.jpg"
    
    echo "[playlist]
    NumberOfEntries=$count" > "$target/0_Playlist.pls"
    
    for i in $(seq -w 1 $count);
    do
      j=$((10#$i))
      n=$(($j-1))
      start=$(echo $json | jq -r ".chapters[$n].start_time")
      end=$(echo $json | jq -r ".chapters[$n].end_time")
      name=$(echo $json | jq -r ".chapters[$n].tags.title")
      ffmpeg -activation_bytes secret -i $input -vn -acodec -map_chapters -1 copy -ss $start -to $end -metadata title="$title $name" "$target/$i $name.$EXT2"
      echo "File$j=$i $name.$EXT2" >> "$target/0_Playlist.pls"
    done
    

    【讨论】:

    • 您不需要j 变量。您可以从0 循环到$((count-1)) 并拥有n=$i,因为jq 理解以零为前缀的索引(例如:jq -r ".chapeters[05]"
    • 它似乎删除了视频,hardocdes AAX 秘密并且到处都有点损坏。但我喜欢播放列表和文件名/元数据的东西。于是我发了一个修正版gist.github.com/akostadinov/…
    【解决方案7】:

    前几天我试图自己拆分 .m4b 有声读物,偶然发现了这个线程和其他线程,但我找不到任何使用批处理脚本的示例。我不知道 python 或 bash,而且我根本不是批处理方面的专家,但我尝试了解如何做到这一点,并想出了以下似乎可行的方法。

    这会将按章节编号的 MP3 文件导出到与源文件相同的路径:

    @echo off
    setlocal enabledelayedexpansion
    for /f "tokens=2,5,7,8 delims=," %%G in ('c:\ffmpeg\bin\ffprobe -i %1 -print_format csv -show_chapters -loglevel error  2^> nul') do (
       set padded=00%%G
       "c:\ffmpeg\bin\ffmpeg" -ss %%H -to %%I -i %1 -vn -c:a libmp3lame -b:a 32k -ac 1 -metadata title="%%J" -id3v2_version 3 -write_id3v1 1 -y "%~dpnx1-!padded:~-3!.mp3"
    )
    

    对于您的视频文件文件,我已将其更改为以下内容,以通过直接复制来处理视频和音频数据。我没有包含章节的视频文件,所以我无法对其进行测试,但我希望它可以工作。

    @echo off
    setlocal enabledelayedexpansion
    for /f "tokens=2,5,7,8 delims=," %%G in ('c:\ffmpeg\bin\ffprobe -i %1 -print_format csv -show_chapters -loglevel error  2^> nul') do (
       set padded=00%%G
       "c:\ffmpeg\bin\ffmpeg" -ss %%H -to %%I -i %1 -c:v copy -c:a copy -metadata title="%%J" -y "%~dpnx1-!padded:~-3!.mkv"
    )
    

    【讨论】:

    • 这个坏了。 -ss-to 应该在 -i 之后,并且 %%J 不应该用引号括起来,因为它已经在引号中。还有%%J包含一个CR字符(0x0D),会导致问题,需要去掉。
    • 另外,因为您使用的是-print_format csv,所以如果标题包含换行符(和/或逗号,可能),则会中断。
    【解决方案8】:

    在python中

    #!/usr/bin/env python3
    
    import sys
    import os
    import subprocess
    import shlex
    
    def split_video(pathToInputVideo):
      command="ffprobe -v quiet -print_format csv -show_chapters "
      args=shlex.split(command)
      args.append(pathToInputVideo)
      output = subprocess.check_output(args, stderr=subprocess.STDOUT, universal_newlines=True)
    
      cpt=0
      for line in iter(output.splitlines()):
        dec=line.split(",")
        st_time=dec[4]
        end_time=dec[6]
        name=dec[7]
    
        command="ffmpeg -i _VIDEO_ -ss _START_ -to _STOP_ -vcodec copy -acodec copy"
        args=shlex.split(command)
        args[args.index("_VIDEO_")]=pathToInputVideo
        args[args.index("_START_")]=st_time
        args[args.index("_STOP_")]=end_time
    
        filename=os.path.basename(pathToInputVideo)
        words=filename.split(".");
        l=len(words)
        ext=words[l-1]
    
        cpt+=1
        filename=" ".join(words[0:l-1])+" - "+str(cpt)+" - "+name+"."+ext
    
        args.append(filename)
        subprocess.call(args)
    
    for video in sys.argv[1:]:
      split_video(video)
    

    【讨论】:

      【解决方案9】:

      NodeJS / JavaScript 中的朴素解决方案

      const probe = function (fpath, debug) {
            var self = this;
            return new Promise((resolve, reject) => {
              var loglevel = debug ? 'debug' : 'error';
              const args = [
                '-v', 'quiet',
                '-loglevel', loglevel,
                '-print_format', 'json',
                '-show_chapters',
                '-show_format',
                '-show_streams',
                '-i', fpath
              ];
              const opts = {
                cwd: self._options.tempDir
              };
              const cb = (error, stdout) => {
                if (error)
                  return reject(error);
                try {
                  const outputObj = JSON.parse(stdout);
                  return resolve(outputObj);
                } catch (ex) {
                  self.logger.error("probe failed %s", ex);
                  return reject(ex);
                }
              };
              console.log(args)
              cp.execFile('ffprobe', args, opts, cb)
                .on('error', reject);
            });
          }//probe
      

      json 输出 raw 对象将包含一个 chapters 数组,其结构如下:

      {
          "chapters": [{
              "id": 0,
              "time_base": "1/1000",
              "start": 0,
              "start_time": "0.000000",
              "end": 145000,
              "end_time": "135.000000",
              "tags": {
                  "title": "This is Chapter 1"
              }
          }]
      }
      

      【讨论】:

        【解决方案10】:

        这是 PowerShell 版本

        $filePath = 'C:\InputVideo.mp4'
        
        $file = Get-Item $filePath
        
        $json = ConvertFrom-Json (ffprobe -i $filePath -print_format json -show_chapters -loglevel error | Out-String)
        
        foreach($chapter in $json.chapters)
        {
            ffmpeg -loglevel error -i $filePath -c copy -ss $chapter.start_time -to $chapter.end_time "$($file.DirectoryName)\$($chapter.id).$($file.Extension)"
        }
        

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2010-12-10
          • 2021-08-14
          • 1970-01-01
          • 2014-04-26
          • 1970-01-01
          • 1970-01-01
          • 2023-03-31
          • 2014-12-19
          相关资源
          最近更新 更多