diff --git a/pelita/game.py b/pelita/game.py index 1f74338fb..d7b83faf9 100644 --- a/pelita/game.py +++ b/pelita/game.py @@ -36,10 +36,10 @@ class TkViewer: - def __init__(self, *, address, controller, geometry=None, delay=None, stop_after=None): - self.proc = self._run_external_viewer(address, controller, geometry=geometry, delay=delay, stop_after=stop_after) + def __init__(self, *, address, controller, geometry=None, delay=None, stop_after=None, snapshot_folder=None): + self.proc = self._run_external_viewer(address, controller, geometry=geometry, delay=delay, stop_after=stop_after, snapshot_folder=snapshot_folder) - def _run_external_viewer(self, subscribe_sock, controller, geometry, delay, stop_after): + def _run_external_viewer(self, subscribe_sock, controller, geometry, delay, stop_after, snapshot_folder): # Something on OS X prevents Tk from running in a forked process. # Therefore we cannot use multiprocessing here. subprocess works, though. viewer_args = [ str(subscribe_sock) ] @@ -51,6 +51,8 @@ def _run_external_viewer(self, subscribe_sock, controller, geometry, delay, stop viewer_args += ["--delay", str(delay)] if stop_after is not None: viewer_args += ["--stop-after", str(stop_after)] + if snapshot_folder is not None: + viewer_args += ["--snapshot-folder", str(snapshot_folder)] tkviewer = 'pelita.scripts.pelita_tkviewer' external_call = [sys.executable, @@ -234,12 +236,14 @@ def setup_viewers(viewers=None, options=None, print_result=True): proc = TkViewer(address=zmq_publisher.socket_addr, controller=viewer_state['controller'].socket_addr, stop_after=options.get('stop_at'), geometry=options.get('geometry'), - delay=options.get('delay')) + delay=options.get('delay'), + snapshot_folder=options.get('snapshot_folder')) else: proc = TkViewer(address=zmq_publisher.socket_addr, controller=None, stop_after=options.get('stop_at'), geometry=options.get('geometry'), - delay=options.get('delay')) + delay=options.get('delay'), + snapshot_folder=options.get('snapshot_folder')) else: raise ValueError(f"Unknown viewer {viewer}.") diff --git a/pelita/scripts/pelita_main.py b/pelita/scripts/pelita_main.py index 3382c6de0..8c233399d 100755 --- a/pelita/scripts/pelita_main.py +++ b/pelita/scripts/pelita_main.py @@ -64,6 +64,8 @@ def long_help(s): metavar='REPLAYFILE', dest='replayfile', const='pelita.dump', nargs='?') parser.add_argument('--store-output', help=long_help('Write all player’s stdout/stderr to the given folder (must exist)'), metavar='FOLDER') +parser.add_argument('--snapshot-folder', help=long_help('Store the replay in this folder'), + metavar='FOLDER', dest='snapshot_folder') parser.add_argument('--list-layouts', action='store_true', help='List all available built-in layouts.') parser.add_argument('--check-team', action="store_true", @@ -216,6 +218,8 @@ def main(): viewers.append(('write-replay-to', args.write_replay)) if args.replayfile: + if args.snapshot_folder: + viewer_options['snapshot_folder'] = args.snapshot_folder viewer_state = game.setup_viewers(viewers, options=viewer_options) if game.controller_exit(viewer_state, await_action='set_initial'): sys.exit(0) diff --git a/pelita/scripts/pelita_tkviewer.py b/pelita/scripts/pelita_tkviewer.py index 3ae7932a9..3ccb7346c 100755 --- a/pelita/scripts/pelita_tkviewer.py +++ b/pelita/scripts/pelita_tkviewer.py @@ -35,6 +35,8 @@ def geometry_string(s): help='delay') parser.add_argument('--stop-after', type=int, metavar="N", help='Stop after N rounds.') +parser.add_argument('--snapshot-folder', help='Store thw replay in this folder', + metavar='FOLDER', dest='snapshot_folder') parser._optionals = parser.add_argument_group('Options') parser.add_argument('--version', help='show the version number and exit', action='store_const', const=True) @@ -56,7 +58,8 @@ def main(): 'controller_address': args.controller_address, 'geometry': args.geometry, 'delay': args.delay, - 'stop_after': args.stop_after + 'stop_after': args.stop_after, + 'snapshot_folder': args.snapshot_folder } v = TkViewer(**{k: v for k, v in list(tkargs.items()) if v is not None}) v.run() diff --git a/pelita/ui/tk_canvas.py b/pelita/ui/tk_canvas.py index 31d129948..82dadc570 100644 --- a/pelita/ui/tk_canvas.py +++ b/pelita/ui/tk_canvas.py @@ -1,5 +1,6 @@ import json import logging +from pathlib import Path import time import zmq @@ -15,6 +16,16 @@ _logger = logging.getLogger(__name__) +NINJA_TEMPLATE = r""" +rule convert + command = convert -alpha off -density 800 -resize 25% -format png $in -append $out + +rule ffmpeg + command = ffmpeg -y -framerate 10 -i snap-%04d.png -start_number 2 -s:v 1424x778 -c:v libx264 -profile:v high -crf 20 -pix_fmt yuv420p $out + +build movie.mp4: ffmpeg {pngs} +{converts} +""" def guess_size(display_string, bounding_width, bounding_height, rel_size=0): no_lines = display_string.count("\n") + 1 @@ -130,7 +141,7 @@ class UI: class TkApplication: def __init__(self, master, controller_address=None, - geometry=None, delay=1, stop_after=None): + geometry=None, delay=1, stop_after=None, snapshot_folder=None): self.master = master self.master.configure(background="white") @@ -178,6 +189,21 @@ def __init__(self, master, controller_address=None, self.ui.status_canvas = tkinter.Frame(master, height=25) self.ui.status_canvas.config(background="white") + if snapshot_folder: + self.snapshot_mode = True + self.snapshot_folder = Path(snapshot_folder) + try: + self.snapshot_folder.mkdir() + except FileExistsError: + pass + #self.quit() + #raise RuntimeError("Folder ‘{}’ already exists. Exiting.".format(snapshot_folder)) from None + self.snapshot_count = 0 + else: + self.snapshot_mode = False + self.snapshot_folder = None + self.snapshot_count = 0 + self.ui.game_canvas = tkinter.Canvas(master) self.ui.game_canvas.config(background="white", bd=0, highlightthickness=0, relief='ridge') self.ui.game_canvas.bind('', lambda e: master.after_idle(self.update)) @@ -335,6 +361,8 @@ def __init__(self, master, controller_address=None, if self.controller_socket: self.master.after_idle(self.request_initial) + if self.snapshot_mode: + self.ui.status_canvas.pack_forget() def init_mesh(self, game_state): width, height = game_state['shape'] @@ -391,6 +419,12 @@ def update(self, game_state=None): self.mesh_graph.screen_width = self.ui.game_canvas.winfo_width() self.mesh_graph.screen_height = self.ui.game_canvas.winfo_height() + if self.snapshot_mode: + # Ideal scaling should be 24 + width = game_state['shape'][0] * 24 + height = game_state['shape'][1] * 24 + self.ui.header_canvas.winfo_height() + self.master.geometry('{width}x{height}'.format(width=width, height=height)) + if self.mesh_graph.screen_width < 600: if self._default_font.cget('size') != 8: self._default_font.configure(size=8) @@ -419,6 +453,25 @@ def update(self, game_state=None): self.size_changed = False + if self.snapshot_mode: + # we need to ensure that the geometry is correct before taking a snapshot + if (not game_state['shape'][0] * 24 == self.ui.game_canvas.winfo_width() and + not game_state['shape'][1] * 24 == self.ui.game_canvas.winfo_height()): + self.master.after_idle(self.update) + return + + if game_state['team_names'][0] is None and game_state['team_names'][1] is None: + return + + header = self.snapshot_folder / 'snap-{:04}.header.ps'.format(self.snapshot_count) + canvas = self.snapshot_folder / 'snap-{:04}.canvas.ps'.format(self.snapshot_count) + state = self.snapshot_folder / 'snap-{:04}.state.json'.format(self.snapshot_count) + ninja = self.snapshot_folder / 'build.ninja' + canvas.write_text(self.ui.game_canvas.postscript(colormode='color')) + header.write_text(self.ui.header_canvas.postscript(colormode='color')) + state.write_text(json.dumps(game_state)) + self.snapshot_count += 1 + def draw_universe(self, game_state): self.mesh_graph.num_x = game_state['shape'][0] self.mesh_graph.num_y = game_state['shape'][1] @@ -957,6 +1010,17 @@ def on_quit(self): """ override for things which must be done when we exit. """ self.running = False + + if self.snapshot_mode: + snap_ids = ['{:04}'.format(snap_id) for snap_id in range(self.snapshot_count)] + headers = map('snap-{}.header.ps'.format, snap_ids) + canvas = map('snap-{}.canvas.ps'.format, snap_ids) + pngs = list(map('snap-{}.png'.format, snap_ids)) + ninja = self.snapshot_folder / 'build.ninja' + converts = "\n".join(map(lambda phc: "build {}: convert {} {}".format(*phc), zip(pngs, headers, canvas))) + ninja_str = NINJA_TEMPLATE.format(pngs=" ".join(pngs), converts=converts) + ninja.write_text(ninja_str) + if self.controller_socket: _logger.debug('---> exit') self.controller_socket.send_json({"__action__": "exit"}) diff --git a/pelita/ui/tk_viewer.py b/pelita/ui/tk_viewer.py index 801225ffb..a926e2971 100644 --- a/pelita/ui/tk_viewer.py +++ b/pelita/ui/tk_viewer.py @@ -73,12 +73,13 @@ class TkViewer: app : The TkApplication class """ - def __init__(self, address, controller_address=None, geometry=None, delay=1, stop_after=None): + def __init__(self, address, controller_address=None, geometry=None, delay=1, stop_after=None, snapshot_folder=None): self.address = address self.controller_address = controller_address self.delay = delay self.geometry = geometry if geometry else (900, 580) self.stop_after = stop_after + self.snapshot_folder = snapshot_folder self.context = zmq.Context() self.socket = self.context.socket(zmq.SUB) @@ -111,7 +112,8 @@ def run(self): controller_address=self.controller_address, geometry=self.geometry, delay=self.delay, - stop_after=self.stop_after) + stop_after=self.stop_after, + snapshot_folder=self.snapshot_folder) # schedule next read self.root.after_idle(self.read_queue) try: