Compare commits

..

No commits in common. "6baacd9b899ef00a6472209532a2c9ce318d4d94" and "b62e9ebbf9a32bc29aa33918cf7ffa606a0aab84" have entirely different histories.

6 changed files with 247 additions and 311 deletions

View file

@ -9,131 +9,111 @@ import numpy as np
import wand.image import wand.image
class Concat(dcc.doom_base.Wad): class Concat(dcc.doom_base.Wad):
def get_parser(self, prog_name): def get_parser(self, prog_name):
parser = super().get_parser(prog_name) parser = super().get_parser(prog_name)
parser.add_argument("start_map") parser.add_argument("start_map")
parser.add_argument("end_map") parser.add_argument("end_map")
parser.add_argument("-n", "--nooverlay", action="store_true") return parser
return parser
def take_action(self, parsed_args): def take_action(self, parsed_args):
logging.basicConfig() logging.basicConfig()
av.logging.set_level(av.logging.VERBOSE) av.logging.set_level(av.logging.VERBOSE)
av.logging.restore_default_callback() av.logging.restore_default_callback()
videos = self.fabricate.joinpath(parsed_args.wad).glob(f"{parsed_args.wad}_map*.mp4") videos = self.fabricate.joinpath(parsed_args.wad).glob(f"{parsed_args.wad}_map*.mp4")
output = av.open(self.fabricate.joinpath(parsed_args.wad).joinpath(f"{parsed_args.wad}_maps{parsed_args.start_map}to{parsed_args.end_map}.mp4"), "w") output = av.open(self.fabricate.joinpath(parsed_args.wad).joinpath(f"{parsed_args.wad}_maps{parsed_args.start_map}to{parsed_args.end_map}.mp4"), "w")
offset = 0 offset = 0
# We'd like to use the concat filter here and connect everything into a # We'd like to use the concat filter here and connect everything into a
# single filter graph... but it produces a "Resource temporarily # single filter graph... but it produces a "Resource temporarily
# unavailable" error when switching to inputs after the first. Presumably # unavailable" error when switching to inputs after the first. Presumably
# fixable, but it's easier to just make one graph per video and mux # fixable, but it's easier to just make one graph per video and mux
# everything together at the end. # everything together at the end.
for v in sorted(videos): for v in sorted(videos):
# TODO: Support UDoom in literally any way. # TODO: Support UDoom in literally any way.
if not (v.name >= f"{parsed_args.wad}_map{parsed_args.start_map}.mp4" and if not (v.name >= f"{parsed_args.wad}_map{parsed_args.start_map}.mp4" and
v.name <= f"{parsed_args.wad}_map{parsed_args.end_map}.mp4"): v.name <= f"{parsed_args.wad}_map{parsed_args.end_map}.mp4"):
continue continue
chunk = av.open(v) chunk = av.open(v)
if not (len(chunk.streams.video) == 1 and len(chunk.streams.audio) == 1): ograph = av.filter.Graph()
raise Exception(f"irregular chunk {v}: streams {chunk.streams} (expected 1 video & 1 audio)") sink = ograph.add("buffersink")
asink = ograph.add("abuffersink")
ograph = av.filter.Graph() img = wand.image.Image(height=chunk.streams[0].height,width=chunk.streams[0].width)
sink = ograph.add("buffersink") mapstring = v.name[-6:-4]
asink = ograph.add("abuffersink") text = self._config["map_names"][f"map{mapstring}"]
dcc.text.draw_text(img, f"MAP{mapstring}: {text}", font_size=120)
img.trim(reset_coords=True)
img.border("graya(25%, 25%)", 10, 10)
img.border(dcc.config.TEXT_STROKE_COLOR, 16, 16)
# for this to work... the image needs to have a width that's a multiple
# of 8. dude whyyyyyyy
padfactor=8
img.border("transparent", padfactor, 0)
img.crop(width=img.width-img.width%padfactor, height=img.height)
text_frame = av.video.frame.VideoFrame(img.width, img.height, format="rgba")
if not parsed_args.nooverlay: if len(output.streams.get()) == 0:
img = wand.image.Image(height=chunk.streams[0].height,width=chunk.streams[0].width) # TODO: less hardcoding.
mapstring = v.name[-6:-4] output.add_stream("h264", rate=61440)
text = self._config["map_names"][f"map{mapstring}"] output.streams[0].extradata = copy.deepcopy(chunk.streams[0].extradata)
dcc.text.draw_text(img, f"MAP{mapstring}: {text}", font_size=120) output.streams[0].height=1440
img.trim(reset_coords=True) output.streams[0].width=2560
img.border("graya(25%, 25%)", 10, 10) output.streams[0].profile="High"
img.border(dcc.config.TEXT_STROKE_COLOR, 16, 16) output.streams[0].qmax = chunk.streams[0].qmax
# for this to work... the image needs to have a width that's a multiple output.streams[0].qmin = chunk.streams[0].qmin
# of 8. dude whyyyyyyy output.streams[0].codec_context.gop_size=30
padfactor=8 output.streams[0].codec_context.max_b_frames=2
img.border("transparent", padfactor, 0) output.streams[0].codec_context.framerate = fractions.Fraction(60,1)
img.crop(width=img.width-img.width%padfactor, height=img.height) output.streams[0].codec_context.pix_fmt="yuv420p"
output.streams[0].codec_context.bit_rate = chunk.streams[0].codec_context.bit_rate
output.add_stream("aac", rate=48000)
output.streams[1].extradata = copy.deepcopy(output.streams[1].extradata)
output.streams[1].rate=48000
output.streams[1].bit_rate=chunk.streams[1].bit_rate
src = ograph.add_buffer(template=chunk.streams[0], time_base=chunk.streams[0].time_base)
asrc = ograph.add_abuffer(template=chunk.streams[1], time_base=chunk.streams[1].time_base)
overlay = ograph.add_buffer(width=img.width, height=img.height, format="rgba", time_base=chunk.streams[0].time_base)
overlay_fo = ograph.add("fade", args="out:240:60")
overlay.link_to(overlay_fo, 0, 0)
composite = ograph.add("overlay", args="x=4:y=4")
src.link_to(composite, 0, 0)
overlay_fo.link_to(composite, 0, 1)
ifade = ograph.add("fade", args="in:0:60")
iafade = ograph.add("afade", args="in:{}:48000".format(offset*48000/1000000))
ofade = ograph.add("fade", args="out:{}:60".format((chunk.duration*60/1000000)-60))
oafade = ograph.add("afade", args="out:{}:48000".format(((offset+chunk.duration)*48000/1000000)-48000))
composite.link_to(ifade, 0, 0)
asrc.link_to(iafade, 0, 0)
ifade.link_to(ofade, 0, 0)
iafade.link_to(oafade, 0, 0)
ofade.link_to(sink, 0, 0)
oafade.link_to(asink, 0, 0)
if len(output.streams.get()) == 0: ograph.configure()
# We can't use the input stream as a template here; it doesn't for packet in chunk.demux():
# have everything needed to do encoding and will fail if packet.dts is None:
# mysteriously later. continue
vs = chunk.streams.video[0] packet.dts += (offset * packet.time_base.denominator) / (packet.time_base.numerator * 1000000)
output.add_stream("h264", rate=int(vs.time_base.denominator/vs.time_base.numerator)) packet.pts += (offset * packet.time_base.denominator) / (packet.time_base.numerator * 1000000)
output.streams[0].extradata = copy.deepcopy(vs.extradata) if packet.stream_index == 0: # TODO: robustness
output.streams[0].height=vs.height for ifr in packet.decode():
output.streams[0].width=vs.width text_frame = av.video.frame.VideoFrame(img.width, img.height, format="rgba")
output.streams[0].qmax = vs.qmax text_frame.planes[0].update(img.make_blob(format="rgba"))
output.streams[0].qmin = vs.qmin text_frame.pts = ifr.pts
output.streams[0].codec_context.bit_rate = vs.codec_context.bit_rate text_frame.dts = ifr.dts
output.streams[0].codec_context.framerate = vs.base_rate text_frame.time_base = ifr.time_base
output.streams[0].codec_context.pix_fmt = vs.codec_context.pix_fmt overlay.push(text_frame)
# The following are only used for encoding and have no equivalent on the input stream. src.push(ifr)
output.streams[0].profile="High" ofr = sink.pull()
output.streams[0].codec_context.gop_size=30 for p in output.streams[packet.stream_index].encode(ofr):
output.streams[0].codec_context.max_b_frames=2 output.mux(p)
else:
astr = chunk.streams.audio[0] for ifr in packet.decode():
output.add_stream("aac", rate=astr.rate) asrc.push(ifr)
output.streams[1].extradata = copy.deepcopy(astr.extradata) ofr = asink.pull()
output.streams[1].bit_rate=astr.bit_rate for p in output.streams[packet.stream_index].encode(ofr):
output.mux(p)
src = ograph.add_buffer(template=chunk.streams.video[0], time_base=chunk.streams.video[0].time_base) offset += chunk.duration
asrc = ograph.add_abuffer(template=chunk.streams.audio[0], time_base=chunk.streams.audio[0].time_base) chunk.close()
# TODO: video fades are absolute relative to the input video; audio output.close()
# fades need to have their timestamps offset by the position in the
# final video. Clarify if this is really necessary.
frame_rate = chunk.streams.video[0].base_rate
sample_rate = chunk.streams.audio[0].rate
ifade = ograph.add("fade", args="in:0:{}".format(frame_rate))
ofade = ograph.add("fade", args="out:{}:{}".format((chunk.duration*frame_rate/1000000)-frame_rate, frame_rate))
iafade = ograph.add("afade", args="in:{}:{}".format(offset*sample_rate/1000000, sample_rate))
oafade = ograph.add("afade", args="out:{}:{}".format(((offset+chunk.duration)*sample_rate/1000000)-sample_rate, sample_rate))
if not parsed_args.nooverlay:
overlay = ograph.add_buffer(width=img.width, height=img.height, format="rgba", time_base=chunk.streams[0].time_base)
overlay_fo = ograph.add("fade", args="out:{}:{}".format(4*frame_rate, frame_rate))
overlay.link_to(overlay_fo, 0, 0)
composite = ograph.add("overlay", args="x=4:y=4")
src.link_to(composite, 0, 0)
overlay_fo.link_to(composite, 0, 1)
composite.link_to(ifade, 0, 0)
else:
src.link_to(ifade, 0, 0)
asrc.link_to(iafade, 0, 0)
ifade.link_to(ofade, 0, 0)
iafade.link_to(oafade, 0, 0)
ofade.link_to(sink, 0, 0)
oafade.link_to(asink, 0, 0)
ograph.configure()
for packet in chunk.demux():
if packet.dts is None:
continue
packet.dts += (offset * packet.time_base.denominator) / (packet.time_base.numerator * 1000000)
packet.pts += (offset * packet.time_base.denominator) / (packet.time_base.numerator * 1000000)
if packet.stream == chunk.streams.video[0]:
for ifr in packet.decode():
if not parsed_args.nooverlay:
text_frame = av.video.frame.VideoFrame(img.width, img.height, format="rgba")
text_frame.planes[0].update(img.make_blob(format="rgba"))
text_frame.pts = ifr.pts
text_frame.dts = ifr.dts
text_frame.time_base = ifr.time_base
overlay.push(text_frame)
src.push(ifr)
ofr = sink.pull()
for p in output.streams[packet.stream_index].encode(ofr):
output.mux(p)
else:
for ifr in packet.decode():
asrc.push(ifr)
ofr = asink.pull()
for p in output.streams[packet.stream_index].encode(ofr):
output.mux(p)
offset += chunk.duration
chunk.close()
output.close()

View file

@ -6,143 +6,114 @@ import os
import re import re
import tomlkit import tomlkit
class Wad(dcc.config.Base): class Wad(dcc.config.Base):
def get_parser(self, prog_name): def get_parser(self, prog_name):
parser = super().get_parser(prog_name) parser = super().get_parser(prog_name)
parser.add_argument('wad') parser.add_argument('wad')
return parser return parser
def wad_init(self, parsed_args): def wad_init(self, parsed_args):
self.init_base(parsed_args) self.init_base(parsed_args)
self._wad = parsed_args.wad self._wad = parsed_args.wad
wcp = self.pwads.joinpath(self.wad).joinpath(self.config_name) wcp = self.pwads.joinpath(self.wad).joinpath(self.config_name)
if wcp.exists(): if wcp.exists():
self._wad_config = tomlkit.toml_file.TOMLFile(wcp).read() self._wad_config = tomlkit.toml_file.TOMLFile(wcp).read()
self._config.update(self._wad_config.value) self._config.update(self._wad_config.value)
#for k,v in self._wad_config.value.items():
#self._config.add(k,v)
def run(self, parsed_args): def run(self, parsed_args):
self.wad_init(parsed_args) self.wad_init(parsed_args)
self.take_action(parsed_args) self.take_action(parsed_args)
@property @property
def wad(self): def wad(self):
return self._wad return self._wad
class WadMap(Wad): class WadMap(Wad):
def get_parser(self, prog_name): def get_parser(self, prog_name):
parser = super().get_parser(prog_name) parser = super().get_parser(prog_name)
parser.add_argument('map') parser.add_argument('map')
parser.add_argument('-n', '--name', '--demo_name') parser.add_argument('-n','--name','--demo_name')
return parser return parser
def run(self, parsed_args): def run(self, parsed_args):
self._map = parsed_args.map self._map = parsed_args.map
self._name = parsed_args.name self._name = parsed_args.name
self.wad_init(parsed_args) self.wad_init(parsed_args)
self.take_action(parsed_args) self.take_action(parsed_args)
@property @property
def map(self): def map(self):
return self._map return self._map
@property
def name_string(self):
return "" if self._name is None else "_" + self._name
@property def dsda_preamble(self):
def name_string(self): args = ["-iwad", self.iwad_path(self.wad)]
return "" if self._name is None else "_" + self._name
def dsda_preamble(self): pwadpath = self.pwads.joinpath(self.wad)
args = ["-iwad", self.iwad_path(self.wad)] wads = sorted(pwadpath.glob('*.wad', case_sensitive=False))
if len(wads) > 0:
args = args + ["-file"] + wads
pwadpath = self.pwads.joinpath(self.wad) dehs = sorted(pwadpath.glob('*.deh', case_sensitive=False))
wads = sorted(pwadpath.glob('*.wad', case_sensitive=False)) if len(dehs) > 0:
if len(wads) > 0: args = args + ["-deh"] + dehs
args = args + ["-file"] + wads
dehs = sorted(pwadpath.glob('*.deh', case_sensitive=False)) args = args + ["-complevel", self.complevel()]
if len(dehs) > 0: args = args + ["-skill", "4"]
args = args + ["-deh"] + dehs args = args + ["-warp", self.map]
return args
args = args + ["-complevel", self.complevel()] def complevel(self):
args = args + ["-skill", "4"] complevel = self.pwads.joinpath(self.wad).joinpath("complevel")
args = args + ["-warp", self.map] if not complevel.exists():
return args raise Exception("No complevel set for wad {}.".format(self.wad))
def complevel(self): with io.open(complevel) as f:
complevel = self.pwads.joinpath(self.wad).joinpath("complevel") return f.read().strip()
if not complevel.exists():
complevel = self._config.get("complevel")
if complevel is not None:
return complevel
raise Exception("No complevel set for wad {}.".format(self.wad))
with io.open(complevel) as f: def demo_in_path(self):
return f.read().strip() candidates = [x for x in self.demos.joinpath(self.wad).glob(self._file_base("*.lmp"))]
if len(candidates) == 0:
raise Exception("no suitable demo candidates for WAD {} MAP {} name {}.".format(self.wad, self.map, self.name_string))
if len(candidates) == 1:
return candidates[0]
return sorted(filter(lambda s : re.search("-", str(s)), candidates))[-1]
def demo_in_path(self): def dsda_text_path(self):
candidates = [ return self._ensure(self.demos.joinpath(self.wad)).joinpath(self._file_base(".txt"))
x for x in self.demos.joinpath(self.wad)
.glob(self._file_base("*.lmp"))
]
if len(candidates) == 0:
raise Exception(
"no suitable demo candidates for WAD {} MAP {} name {}."
.format(self.wad, self.map, self.name_string)
)
if len(candidates) == 1:
return candidates[0]
return sorted(filter(lambda s: re.search("-", str(s)), candidates))[-1]
def dsda_text_path(self): def video_path(self):
return ( return self._ensure(self.fabricate.joinpath(self.wad)).joinpath(self._file_base(".mp4"))
self._ensure(self.demos.joinpath(self.wad))
.joinpath(self._file_base(".txt"))
)
def video_path(self): def demo_out_path(self):
return ( return self._ensure(self.demos.joinpath(self.wad)).joinpath(self._file_base(".lmp"))
self._ensure(self.fabricate.joinpath(self.wad))
.joinpath(self._file_base(".mp4"))
)
def demo_out_path(self): def target_bucket(self):
return ( return "doom/" + self._file_base(".lmp")
self._ensure(self.demos.joinpath(self.wad))
.joinpath(self._file_base(".lmp"))
)
def target_bucket(self): def base_thumb_path(self):
return "doom/" + self._file_base(".lmp") return self._ensure(self.fabricate.joinpath(self.wad)).joinpath(self._file_base("_base.png"))
def base_thumb_path(self): def m_doom_path(self):
return ( return self._ensure(self.fabricate.joinpath(self.wad)).joinpath("M_DOOM_scaled.png")
self._ensure(self.fabricate.joinpath(self.wad))
.joinpath(self._file_base("_base.png"))
)
def m_doom_path(self): def text_thumb_path(self):
return ( return self._ensure(self.fabricate.joinpath(self.wad)).joinpath(self._file_base("_text.png"))
self._ensure(self.fabricate.joinpath(self.wad))
.joinpath("M_DOOM_scaled.png")
)
def text_thumb_path(self): def thumb_path(self):
return ( return self._ensure(self.fabricate.joinpath(self.wad)).joinpath(self._file_base("_thumb.png"))
self._ensure(self.fabricate.joinpath(self.wad))
.joinpath(self._file_base("_text.png"))
)
def thumb_path(self): def _file_base(self, ext):
return ( return "{}_map{}{}{}".format(self.wad, self.map, self.name_string, ext)
self._ensure(self.fabricate.joinpath(self.wad))
.joinpath(self._file_base("_thumb.png"))
)
def _file_base(self, ext): def _ensure(self, path):
return "{}_map{}{}{}".format(self.wad, self.map, self.name_string, ext) if not path.exists():
os.mkdir(path)
def _ensure(self, path): return path
if not path.exists():
os.mkdir(path)
return path

View file

@ -5,43 +5,32 @@ import pathlib
import urllib.request import urllib.request
import zipfile import zipfile
class Fetch(dcc.config.Base): class Fetch(dcc.config.Base):
def get_parser(self, prog_name): def get_parser(self, prog_name):
parser = super().get_parser(prog_name) parser = super().get_parser(prog_name)
parser.add_argument("id_or_name") parser.add_argument("id_or_name")
return parser return parser
def take_action(self, parsed_args):
idgames_id = parsed_args.id_or_name
if not parsed_args.id_or_name.isdigit():
idgames_id = self.search_idgames(parsed_args.id_or_name)
def take_action(self, parsed_args): with urllib.request.urlopen("https://www.doomworld.com/idgames/api/api.php?action=get&id={}&out=json".format(idgames_id)) as response:
idgames_id = parsed_args.id_or_name reply = json.loads(response.read())
if not parsed_args.id_or_name.isdigit(): rpath = "/".join([dcc.config.MIRROR, reply["content"]["dir"], reply["content"]["filename"]])
idgames_id = self.search_idgames(parsed_args.id_or_name) wad = reply["content"]["filename"][0:-4]
with urllib.request.urlopen( with urllib.request.urlopen(rpath) as response:
"https://www.doomworld.com/idgames/api/" + z = zipfile.ZipFile(io.BytesIO(response.read()))
"api.php?action=get&id={}&out=json".format(idgames_id) z.extractall(path=self.pwads.joinpath(wad))
) as response:
reply = json.loads(response.read())
rpath = "/".join([
dcc.config.MIRROR,
reply["content"]["dir"],
reply["content"]["filename"]
])
wad = reply["content"]["filename"][0:-4]
with urllib.request.urlopen(rpath) as response: # TODO: explicit error handling. Let users choose when >1 result.
z = zipfile.ZipFile(io.BytesIO(response.read())) def search_idgames(self, wad):
z.extractall(path=self.pwads.joinpath(wad)) with urllib.request.urlopen("https://www.doomworld.com/idgames/api/api.php?action=search&query={}&out=json".format(wad)) as response:
reply = json.loads(response.read())
# TODO: explicit error handling. Let users choose when >1 result. files = reply["content"]["file"]
def search_idgames(self, wad): if type(files) is dict: # One result.
with urllib.request.urlopen( return files["id"]
"https://www.doomworld.com/idgames/api/" + else: # More than one. Zero will raise an error.
"api.php?action=search&query={}&out=json".format(wad) return files[0]["id"]
) as response:
reply = json.loads(response.read())
files = reply["content"]["file"]
if type(files) is dict: # One result.
return files["id"]
else: # More than one. Zero will raise an error.
return files[0]["id"]

View file

@ -2,7 +2,6 @@ import dcc.config
import dcc.doom_base import dcc.doom_base
import subprocess import subprocess
class Play(dcc.doom_base.WadMap): class Play(dcc.doom_base.WadMap):
def take_action(self, parsed_args): def take_action(self, parsed_args):
subprocess.run([self.dsda] + self.dsda_preamble()) subprocess.run([self.dsda] + self.dsda_preamble())

View file

@ -2,10 +2,7 @@ import dcc.config
import dcc.doom_base import dcc.doom_base
import subprocess import subprocess
class Record(dcc.doom_base.WadMap): class Record(dcc.doom_base.WadMap):
def take_action(self, parsed_args): def take_action(self, parsed_args):
subprocess.run( subprocess.run([self.dsda] + self.dsda_preamble() +
[self.dsda] + self.dsda_preamble() + ["-record", self.demo_out_path()])
["-record", self.demo_out_path()]
)

View file

@ -8,35 +8,35 @@ VERSION = '0.0.1'
long_description = '' long_description = ''
setup( setup(
name=PROJECT, name=PROJECT,
version=VERSION, version=VERSION,
description='Doom Command Center', description='Doom Command Center',
long_description=long_description, long_description=long_description,
author='yrriban', author='yrriban',
author_email='yrriban@gmail.com', author_email='yrriban@gmail.com',
platforms=['Any'], platforms=['Any'],
install_requires=['cliff'], install_requires=['cliff'],
packages=find_packages(), packages=find_packages(),
include_package_data=True, include_package_data=True,
entry_points={ entry_points={
'console_scripts': ['dcc=dcc.main:main'], 'console_scripts': ['dcc=dcc.main:main'],
'dcc': [ 'dcc': [
'play = dcc.play:Play', 'play = dcc.play:Play',
'record = dcc.record:Record', 'record = dcc.record:Record',
'fabricate = dcc.fabricate:Fabricate', 'fabricate = dcc.fabricate:Fabricate',
'put = dcc.put:Put', 'put = dcc.put:Put',
'pb = dcc.pb:PB', 'pb = dcc.pb:PB',
'ss = dcc.ss:SS', 'ss = dcc.ss:SS',
'extract = dcc.extract:Extract', 'extract = dcc.extract:Extract',
'fetch = dcc.fetch:Fetch', 'fetch = dcc.fetch:Fetch',
'text = dcc.text:Text', 'text = dcc.text:Text',
'thumb = dcc.thumb:Thumb', 'thumb = dcc.thumb:Thumb',
'dsda = dcc.dsda:DSDA', 'dsda = dcc.dsda:DSDA',
'eureka = dcc.eureka:Eureka', 'eureka = dcc.eureka:Eureka',
'ls = dcc.ls:List', 'ls = dcc.ls:List',
'configure = dcc.configure:Configure', 'configure = dcc.configure:Configure',
'concat = dcc.concat:Concat', 'concat = dcc.concat:Concat',
], ],
}, },
zip_safe=False, zip_safe=False,
) )