Last active
June 5, 2022 17:44
-
-
Save hhsprings/e04adb1ac9baaa82d82b117b88f61972 to your computer and use it in GitHub Desktop.
To add "ffmetadata1" to video
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# -*- 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