Skip to content

Instantly share code, notes, and snippets.

@hhsprings
Last active June 5, 2022 17:44
Show Gist options
  • Save hhsprings/e04adb1ac9baaa82d82b117b88f61972 to your computer and use it in GitHub Desktop.
Save hhsprings/e04adb1ac9baaa82d82b117b88f61972 to your computer and use it in GitHub Desktop.
To add "ffmetadata1" to video
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from __future__ import print_function
import io
import os
import re
import sys
import subprocess
import tempfile
import json
import csv
if hasattr("", "decode"):
_encode = lambda s: s.encode(sys.getfilesystemencoding())
_decode = lambda s: s.decode(sys.getfilesystemencoding())
def _fromcsv(fn):
reader = csv.reader(io.open(fn, "rb"))
return [list(map(lambda s: s.decode("utf-8"), line)) for line in reader if line]
else:
_encode = lambda s: s
_decode = lambda s: s
def _fromcsv(fn):
reader = csv.reader(io.open(fn, encoding="utf-8"))
return list(filter(None, reader))
def parse_time(s):
"""
>>> print("%.3f" % parse_time(3.2))
3.200
>>> print("%.3f" % parse_time(3))
3.000
>>> print("%.3f" % parse_time("00:00:01"))
1.000
>>> print("%.3f" % parse_time("00:00:01.3"))
1.300
>>> print("%.3f" % parse_time("00:00:01.34"))
1.340
>>> print("%.3f" % parse_time("00:00:01.034"))
1.034
>>> print("%.3f" % parse_time("00:00:01.345"))
1.345
>>> print("%.3f" % parse_time("00:01:01.345"))
61.345
>>> print("%.3f" % parse_time("02:01:01.345"))
7261.345
>>> print("%.3f" % parse_time("01:01.345"))
61.345
"""
try:
return float(s)
except ValueError:
if "." in s:
n, _, ss = s.rpartition(".")
else:
n, ss = s, "0"
n = n.split(":")
if len(n) > 3:
raise ValueError("'{}' is not valid time.".format(s))
result = sum([
p * 60**(len(n) - 1 - i)
for i, p in enumerate(list(map(int, n)))])
result += int(ss) / float((10**len(ss)))
return result
def _get_duration(video):
cmdl = [
"ffprobe",
"-hide_banner",
"-show_streams",
video,
]
tfn = tempfile.mktemp()
try:
with io.open(tfn, "wb") as err:
raw = subprocess.check_output(
list(map(_encode, filter(None, cmdl))), stderr=err)
stderrout = io.open(tfn, "rb").read()
return parse_time(
re.search(br" Duration: ([\d:.]+),", stderrout).group(1).decode())
finally:
if os.path.exists(tfn):
os.remove(tfn)
def _parse_ffmetadata1(fn):
"""
The file format is as follows:
1. A file consists of a header and a number of metadata tags divided
into sections, each on its own line.
2. The header is a ‘;FFMETADATA’ string, followed by a version number
(now 1).
3. Metadata tags are of the form ‘key=value’
4. Immediately after header follows global metadata
5. After global metadata there may be sections with per-stream/per-chapter
metadata.
6. A section starts with the section name in uppercase (i.e. STREAM or
CHAPTER) in brackets (‘[’, ‘]’) and ends with next section or end of file.
7. At the beginning of a chapter section there may be an optional timebase
to be used for start/end values. It must be in form ‘TIMEBASE=num/den’,
where num and den are integers. If the timebase is missing then start/end
times are assumed to be in nanoseconds.
Next a chapter section must contain chapter start and end times in form
‘START=num’, ‘END=num’, where num is a positive integer.
8. Empty lines and lines starting with ‘;’ or ‘#’ are ignored.
9. Metadata keys or values containing special characters (‘=’, ‘;’, ‘#’,
‘\’ and a newline) must be escaped with a backslash ‘\’.
10. Note that whitespace in metadata (e.g. ‘foo = bar’) is considered to
be a part of the tag (in the example above key is ‘foo ’, value is ‘ bar’).
"""
def _expand(s):
return re.sub(r"\\([=;#\\])", r"\1", s)
result = {"": {}, "chapter": [], "stream": []}
with io.open(fn, encoding="utf-8-sig") as f:
lines = list(filter(None, re.split(r"\r?\n", f.read())))
itr = iter(lines)
hdr = next(itr)
if re.sub(r"\s", "", hdr) != ";FFMETADATA1":
raise ValueError("invalid FFMETADATA1")
def _rl(lines):
prev = ""
for line in lines:
if line[0] in (";", "#"):
continue
if line[-1] == "\\" and not (
len(line) > 2 and line[-2] == "\\"):
prev += line[:-1] + "\n"
else:
if prev:
r = prev + line
prev = ""
else:
r = line
yield r
if prev:
yield prev
section = ""
for line in _rl(list(itr)):
if line in ("[CHAPTER]", "[STREAM]"):
section = line[1:-1].lower()
result[section].append({})
continue
m = re.search(r"(?<!\\)=", line)
idx = m.span()[0]
k, v = line[:idx], line[idx + 1:]
k, v = _expand(k), _expand(v)
if not section:
result[section][k] = v
else:
result[section][-1][k] = v
chaps = result["chapter"]
for i, chap in enumerate(chaps):
tb = chap.get("TIMEBASE", "1/1000000000")
fac = eval("1. * " + tb)
tit = chap.get("title")
st = float(chap.get("START", 0)) * fac
ed = float(chap.get("END", 0)) * fac
result["chapter"][i] = (tit, st, ed)
#print(json.dumps(result, indent=4, ensure_ascii=False))
return result
class FfmetadataBuilder(object):
def __init__(self, video):
self._video = video
self._result = {"": {}, "chapter": [], "stream": {}}
def load(self, metadef_file):
"""
json format:
============================================
{
"": {
"title": "..."
},
"chapter": [
[title, start, end], ...
],
"stream": [
{
"title": "..."
}
]
}
============================================
csv format (which can be used if you have only chapters):
============================================
title, start, end
...
============================================
"""
raw = {"": {}, "chapter": [], "stream": []}
for suf in ("", ".json", ".csv"):
fn = metadef_file + suf
if not os.path.exists(fn):
continue
try:
raw = json.load(io.open(fn, encoding="utf-8-sig"))
break
except Exception as e:
try:
raw = _parse_ffmetadata1(fn)
break
except Exception as e:
# title,start,end
raw["chapter"] = _fromcsv(fn)
break
else:
raise ValueError("input file was not found")
for k in raw.get("", {}).keys():
self._result[""][k] = raw[""].get(k)
self._result["stream"] = raw["stream"]
self._result["chapter"] = []
for i, line in enumerate(raw["chapter"]):
if len(line) != 3:
continue
tit, st, ed = line
if not tit:
tit = "#{}".format(i + 1)
if st:
st = parse_time(st)
elif len(self._result["chapter"]):
st = self._result["chapter"][-1][2]
else:
st = 0.0
if ed:
ed = parse_time(ed)
elif i == len(raw["chapter"]) - 1:
ed = _get_duration(self._video)
else:
ed = None
if st and len(self._result["chapter"]) and self._result["chapter"][-1][2] is None:
self._result["chapter"][-1][2] = st
self._result["chapter"].append([tit, st, ed])
return self
def write(self):
def _esc(s):
return re.sub(r"([\n=;#\\])", r"\\\1", s)
ofn = self._video + ".ffmetadata1"
with io.open(ofn, "w", encoding="utf-8", newline="\n") as fo:
print(";FFMETADATA1", file=fo)
for k in self._result[""].keys():
v = self._result[""][k]
print("{}={}".format(_esc(k), _esc(v)), file=fo)
for i, chap in enumerate(self._result["chapter"]):
tit, st, ed = chap
#print(i, chap)
print("""[CHAPTER]
TIMEBASE={}
START={}
END={}
title={}""".format(
"1/1000000000",
int(st * 1000000000), int(ed * 1000000000),
_esc(tit)), file=fo)
for s in self._result["stream"]:
print("[STREAM]", file=fo)
for k in s.keys():
v = s[k]
print("{}={}".format(_esc(k), _esc(v)), file=fo)
return ofn
if __name__ == '__main__':
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("inputvideo")
ap_wrgrp = ap.add_mutually_exclusive_group()
ap_wrgrp.add_argument("--outputvideo")
ap_wrgrp.add_argument("--overwrite", action="store_true")
ap_wrgrp.add_argument("--clear", action="store_true")
ap.add_argument("--metadef_file")
ap.add_argument("--id3v2_version", type=int, default=0)
args = ap.parse_args()
inputvideo = _decode(args.inputvideo)
base, ext = os.path.splitext(inputvideo)
if args.outputvideo:
outputvideo = _decode(args.outputvideo)
elif args.overwrite or args.clear:
outputvideo = inputvideo
inputvideo = inputvideo + ".orig"
i = 1
while os.path.exists(inputvideo):
inputvideo = os.path.splitext(
inputvideo)[0] + ".orig{}".format(i)
i += 1
os.rename(outputvideo, inputvideo)
else:
outputvideo = base + ".out" + ext
if args.metadef_file:
metadef_file = _decode(args.metadef_file)
else:
metadef_file = base
builder = FfmetadataBuilder(inputvideo)
ffmetafn = builder.load(metadef_file).write()
cmdl = [
"ffmpeg",
"-i", inputvideo,
"-i", ffmetafn,
]
if args.clear:
cmdl += [
"-map_chapters", "-1",
"-map_metadata", "-1",
]
else:
cmdl += [
"-map_metadata", "1",
]
cmdl += [
"-c", "copy",
]
if args.id3v2_version > 0:
cmdl += ["-id3v2_version", "{}".format(args.id3v2_version)]
cmdl += [
outputvideo
]
try:
subprocess.check_call(list(map(_encode, filter(None, cmdl))))
finally:
os.remove(ffmetafn)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment