Merge pull request #7 from catseye/develop-0.8
Develop 0.8
Chris Pressey authored 1 year, 10 months ago
GitHub committed 1 year, 10 months ago
0 | 0 | kinoje |
1 | 1 | ====== |
2 | 2 | |
3 | _Version 0.7_ | |
3 | _Version 0.8_ | |
4 | 4 | | _Entry_ [@ catseye.tc](https://catseye.tc/node/kinoje) |
5 | 5 | | _See also:_ [Canvas Feedback](https://github.com/catseye/Canvas-Feedback#readme) |
6 | 6 | |
17 | 17 | |
18 | 18 | The following are required: |
19 | 19 | |
20 | * **Python** 2.7 or 3.x — to run the script | |
20 | * **Python** 3.x — to run the script (2.7 may or may not work) | |
21 | 21 | * **PyYAML** and **Jinja2** — to fill out the templates |
22 | 22 | * something to create images from filled-out templates — typically **POV-Ray** or **rsvg** |
23 | 23 | * **ffmpeg** or **ImageMagick** — to compile the images into a movie file |
24 | 24 | |
25 | 25 | You might also find VLC useful, for viewing the final movie file. |
26 | 26 | |
27 | On Ubuntu 16.04, you can install these with: | |
27 | On Ubuntu 20.04, you can install these with: | |
28 | 28 | |
29 | pip install --user Jinja2 PyYAML | |
29 | pip install --user Jinja2 PyYAML # you may want to make a virtualenv first | |
30 | 30 | sudo apt install povray povray-includes librsvg2 ffmpeg imagemagick vlc |
31 | 31 | |
32 | 32 | (Or, if you would like to use Docker, you can pull a Docker image from |
37 | 37 | |
38 | 38 | bin/kinoje eg/moebius.yaml |
39 | 39 | |
40 | Since no output filename was given, kinoje assumes MP4 format and automatically picks a reasonable | |
41 | filename, in this case `moebius.mp4`. | |
40 | Since no output filename was given, kinoje assumes MPEG Layer 4 format and automatically | |
41 | picks a reasonable filename, in this case `moebius.mp4`. | |
42 | ||
43 | Other Invokation Options | |
44 | ------------------------ | |
42 | 45 | |
43 | 46 | You can also ask it to create a GIF by specifying an output filename with that as its file extension: |
44 | 47 | |
45 | 48 | bin/kinoje eg/squares.yaml -o squares.gif |
49 | ||
50 | Multiple configuration files can be specified on the command line; successive | |
51 | configurations will act as overlays, overriding the settings in them. In fact | |
52 | individual settings can be given in the format `+name=value`. For example, | |
53 | ||
54 | bin/kinoje eg/moebius.yaml overlays/tiny.yaml +duration=4.0 | |
55 | ||
56 | The `--work-dir` option can be given to make `kinoje` store its working | |
57 | files in a specified directory. The directory will be created if it does | |
58 | not yet exist. If the directory contains incomplete working files from a | |
59 | previous run of `kinoje`, the process will continue where it left off. | |
46 | 60 | |
47 | 61 | Theory of Operation |
48 | 62 | ------------------- |
1 | 1 | import os |
2 | 2 | import sys |
3 | 3 | |
4 | from kinoje.utils import BaseProcessor, Executor, load_config_file, zrange | |
4 | from kinoje.utils import BaseProcessor, load_config_file, zrange | |
5 | 5 | |
6 | 6 | |
7 | 7 | SUPPORTED_OUTPUT_FORMATS = ('.m4v', '.mp4', '.gif') |
49 | 49 | |
50 | 50 | # -strip is there to force convert to process all input files. (if no transformation is given, |
51 | 51 | # it can sometimes stop reading input files. leading to skippy animations. who knows why.) |
52 | self.exe.do_it("convert -delay %s -loop 0 %s -strip %s" % ( | |
53 | delay, filespec, self.outfilename | |
54 | )) | |
52 | self.exe.do_it( | |
53 | ["convert", "-delay", delay, "-loop", "0", filespec, "-strip", self.outfilename], | |
54 | shell=False | |
55 | ) | |
55 | 56 | |
56 | 57 | def view(self): |
57 | self.exe.do_it("eog %s" % self.outfilename) | |
58 | self.exe.do_it(["eog", self.outfilename], shell=False) | |
58 | 59 | |
59 | 60 | |
60 | 61 | class MpegCompiler(Compiler): |
62 | 63 | def compile(self, num_frames): |
63 | 64 | ifmt = os.path.join(self.dirname, self.frame_fmt) |
64 | 65 | # fun fact: even if you say -r 30, ffmpeg still picks 25 fps |
65 | cmd = "ffmpeg -i %s -c:v libx264 -profile:v baseline -pix_fmt yuv420p -r %s -y %s" % ( | |
66 | ifmt, int(self.config['fps']), self.outfilename | |
66 | self.exe.do_it( | |
67 | ["ffmpeg", "-i", ifmt, "-c:v", "libx264", "-profile:v", "baseline", "-pix_fmt", "yuv420p", | |
68 | "-r", str(int(self.config['fps'])), "-y", self.outfilename], | |
69 | shell=False | |
67 | 70 | ) |
68 | self.exe.do_it(cmd) | |
69 | 71 | |
70 | 72 | def view(self): |
71 | self.exe.do_it("vlc %s" % self.outfilename) | |
73 | self.exe.do_it(["vlc", self.outfilename], shell=False) | |
72 | 74 | |
73 | 75 | |
74 | 76 | def main(): |
94 | 96 | argparser.add_argument("--view", default=False, action='store_true', |
95 | 97 | help="Display the resultant movie." |
96 | 98 | ) |
97 | argparser.add_argument('--version', action='version', version="%(prog)s 0.7") | |
99 | argparser.add_argument('--version', action='version', version="%(prog)s 0.8") | |
98 | 100 | |
99 | 101 | options = argparser.parse_args(sys.argv[1:]) |
100 | 102 |
5 | 5 | |
6 | 6 | from jinja2 import Template |
7 | 7 | |
8 | from kinoje.utils import BaseProcessor, Executor, fmod, tween, load_config_file, items, zrange | |
8 | from kinoje.utils import BaseProcessor, fmod, tween, load_config_file, items, zrange | |
9 | 9 | |
10 | 10 | |
11 | 11 | class Expander(BaseProcessor): |
20 | 20 | self.fun_context[key] = eval("lambda x: " + value) |
21 | 21 | |
22 | 22 | def fillout_template(self, frame, t): |
23 | output_filename = os.path.join(self.dirname, "%08d.txt" % frame) | |
24 | if os.path.isfile(output_filename): | |
25 | return | |
23 | 26 | context = copy(self.config) |
24 | 27 | context.update(self.fun_context) |
25 | 28 | context.update({ |
30 | 33 | 'tween': tween, |
31 | 34 | 'fmod': fmod, |
32 | 35 | }) |
33 | output_filename = os.path.join(self.dirname, "%08d.txt" % frame) | |
34 | 36 | with open(output_filename, 'w') as f: |
35 | 37 | f.write(self.template.render(context)) |
36 | 38 | |
51 | 53 | argparser.add_argument('instantsdir', metavar='DIRNAME', type=str, |
52 | 54 | help='Directory that will be populated with instants (text files describing frames)' |
53 | 55 | ) |
54 | argparser.add_argument('--version', action='version', version="%(prog)s 0.7") | |
56 | argparser.add_argument('--version', action='version', version="%(prog)s 0.8") | |
55 | 57 | |
56 | 58 | options = argparser.parse_args(sys.argv[1:]) |
57 | 59 |
0 | from datetime import datetime, timedelta | |
1 | 0 | from argparse import ArgumentParser |
2 | 1 | import os |
3 | import re | |
4 | 2 | import sys |
5 | from tempfile import mkdtemp, mkstemp | |
3 | from tempfile import mkdtemp | |
6 | 4 | |
7 | 5 | try: |
8 | 6 | from tqdm import tqdm |
13 | 11 | from kinoje.renderer import Renderer |
14 | 12 | from kinoje.compiler import Compiler, SUPPORTED_OUTPUT_FORMATS |
15 | 13 | |
16 | from kinoje.utils import LoggingExecutor, load_config_file | |
14 | from kinoje.utils import LoggingExecutor, load_config_files | |
17 | 15 | |
18 | 16 | |
19 | 17 | def main(): |
20 | 18 | argparser = ArgumentParser() |
21 | 19 | |
22 | argparser.add_argument('configfile', metavar='FILENAME', type=str, | |
20 | argparser.add_argument('configfiles', metavar='FILENAME', type=str, nargs='+', | |
23 | 21 | help='A YAML file containing the template to render for each frame, ' |
24 | 'as well as configuration for rendering the template.' | |
22 | 'as well as configuration for rendering the template. ' | |
23 | 'If multiple such configuration files are specified, successive ' | |
24 | 'files are applied as overlays.' | |
25 | 25 | ) |
26 | 26 | argparser.add_argument('-o', '--output', metavar='FILENAME', type=str, default=None, |
27 | 27 | help='The movie file to create. The extension of this filename ' |
29 | 29 | 'If not given, a default name will be chosen based on the ' |
30 | 30 | 'configuration filename with a .mp4 extension added.' % (SUPPORTED_OUTPUT_FORMATS,) |
31 | 31 | ) |
32 | argparser.add_argument('--version', action='version', version="%(prog)s 0.7") | |
32 | argparser.add_argument('-d', '--work-dir', metavar='DIRNAME', type=str, default=None, | |
33 | help='The directory to store intermediate files in while creating ' | |
34 | 'this movie. If not given, a directory will be created in ' | |
35 | 'the system temporary directory.' | |
36 | ) | |
37 | argparser.add_argument('--version', action='version', version="%(prog)s 0.8") | |
33 | 38 | |
34 | options, unknown = argparser.parse_known_args(sys.argv[1:]) | |
35 | remainder = ' '.join(unknown) | |
39 | options, _unknown = argparser.parse_known_args(sys.argv[1:]) | |
36 | 40 | |
37 | 41 | if options.output is None: |
38 | (configbase, configext) = os.path.splitext(os.path.basename(options.configfile)) | |
42 | (configbase, configext) = os.path.splitext(os.path.basename(options.configfiles[0])) | |
39 | 43 | output_filename = configbase + '.mp4' |
40 | 44 | else: |
41 | 45 | output_filename = options.output |
42 | 46 | |
43 | 47 | CompilerClass = Compiler.get_class_for(output_filename) |
44 | 48 | |
45 | config = load_config_file(options.configfile) | |
49 | config = load_config_files(options.configfiles) | |
46 | 50 | |
47 | fd, log_filename = mkstemp() | |
51 | if options.work_dir: | |
52 | work_dir = options.work_dir | |
53 | if not os.path.isdir(work_dir): | |
54 | os.mkdir(work_dir) | |
55 | else: | |
56 | work_dir = mkdtemp() | |
57 | ||
58 | # TODO: append to this log if it already exists | |
59 | log_filename = os.path.join(work_dir, 'kinoje.log') | |
48 | 60 | exe = LoggingExecutor(log_filename) |
49 | 61 | |
50 | instants_dir = mkdtemp() | |
51 | frames_dir = mkdtemp() | |
62 | instants_dir = os.path.join(work_dir, 'instants') | |
63 | if not os.path.isdir(instants_dir): | |
64 | os.mkdir(instants_dir) | |
52 | 65 | |
53 | print('expanding template to instants...') | |
66 | frames_dir = os.path.join(work_dir, 'frames') | |
67 | if not os.path.isdir(frames_dir): | |
68 | os.mkdir(frames_dir) | |
69 | ||
70 | print('expanding template to instants in {}...'.format(instants_dir)) | |
54 | 71 | expander = Expander(config, instants_dir, exe=exe, tqdm=tqdm) |
55 | 72 | expander.expand_all() |
56 | 73 | |
57 | print('rendering instants to frames...') | |
74 | print('rendering instants to frames in {}...'.format(frames_dir)) | |
58 | 75 | renderer = Renderer(config, instants_dir, frames_dir, exe=exe, tqdm=tqdm) |
59 | 76 | renderer.render_all() |
60 | 77 | |
61 | print('compiling frames to movie...') | |
78 | print('compiling frames to movie file "{}"...'.format(output_filename)) | |
62 | 79 | compiler = CompilerClass(config, frames_dir, output_filename, exe=exe, tqdm=tqdm) |
63 | 80 | compiler.compile_all() |
64 | 81 | |
65 | 82 | exe.close() |
66 | os.close(fd) |
2 | 2 | import os |
3 | 3 | import sys |
4 | 4 | |
5 | import yaml | |
6 | try: | |
7 | from yaml import CLoader as Loader | |
8 | except ImportError: | |
9 | from yaml import Loader | |
10 | ||
11 | from kinoje.utils import BaseProcessor, Executor, load_config_file | |
5 | from kinoje.utils import BaseProcessor, load_config_file | |
12 | 6 | |
13 | 7 | |
14 | 8 | class Renderer(BaseProcessor): |
30 | 24 | frame = int(match.group(1)) |
31 | 25 | destname = "%08d.png" % frame |
32 | 26 | full_destname = os.path.join(self.dest, destname) |
27 | if os.path.isfile(full_destname): | |
28 | continue | |
33 | 29 | self.render(frame, full_srcname, full_destname) |
34 | 30 | |
35 | 31 | def render(self, frame, full_srcname, full_destname): |
55 | 51 | argparser.add_argument('framesdir', metavar='DIRNAME', type=str, |
56 | 52 | help='Directory that will be populated with images, one for each frame' |
57 | 53 | ) |
58 | argparser.add_argument('--version', action='version', version="%(prog)s 0.7") | |
54 | argparser.add_argument('--version', action='version', version="%(prog)s 0.8") | |
59 | 55 | |
60 | 56 | options = argparser.parse_args(sys.argv[1:]) |
61 | 57 |
0 | 0 | import os |
1 | import re | |
1 | 2 | import sys |
2 | 3 | from subprocess import check_call |
3 | 4 | |
26 | 27 | |
27 | 28 | |
28 | 29 | def load_config_file(filename): |
30 | return load_config_files([filename]) | |
31 | ||
32 | ||
33 | def load_config_files(filenames): | |
29 | 34 | import yaml |
30 | 35 | try: |
31 | 36 | from yaml import CLoader as Loader |
32 | 37 | except ImportError: |
33 | 38 | from yaml import Loader |
34 | 39 | |
35 | with open(filename, 'r') as file_: | |
36 | config = yaml.load(file_, Loader=Loader) | |
40 | config = {} | |
41 | for filename in filenames: | |
42 | match = re.match(r'^\+(.*?)\=(.*?)$', filename) | |
43 | if match: | |
44 | config[match.group(1)] = float(match.group(2)) | |
45 | else: | |
46 | with open(filename, 'r') as file_: | |
47 | config.update(yaml.load(file_, Loader=Loader)) | |
37 | 48 | |
38 | 49 | config['libdir'] = os.path.dirname(filename) or '.' |
39 | 50 | |
57 | 68 | return config |
58 | 69 | |
59 | 70 | |
60 | class LoggingExecutor(object): | |
71 | class Executor(object): | |
72 | def __init__(self): | |
73 | self.log = sys.stdout | |
74 | ||
75 | def do_it(self, cmd, shell=True, **kwargs): | |
76 | disp_cmd = cmd if shell else ' '.join(cmd) | |
77 | self.log.write('>>> {}\n'.format(disp_cmd)) | |
78 | self.log.flush() | |
79 | try: | |
80 | check_call(cmd, shell=shell, stdout=self.log, stderr=self.log, **kwargs) | |
81 | except Exception as exc: | |
82 | self.close() | |
83 | self.cleanup(exc) | |
84 | ||
85 | def cleanup(self, exc): | |
86 | print(str(exc)) | |
87 | sys.exit(1) | |
88 | ||
89 | def close(self): | |
90 | pass | |
91 | ||
92 | ||
93 | class LoggingExecutor(Executor): | |
61 | 94 | def __init__(self, filename): |
62 | 95 | self.filename = filename |
63 | 96 | self.log = open(filename, 'w') |
64 | 97 | print("logging to {}".format(self.filename)) |
65 | 98 | |
66 | def do_it(self, cmd, **kwargs): | |
67 | self.log.write('>>> {}\n'.format(cmd)) | |
68 | self.log.flush() | |
69 | try: | |
70 | check_call(cmd, shell=True, stdout=self.log, stderr=self.log, **kwargs) | |
71 | except Exception as e: | |
72 | self.log.close() | |
73 | print(str(e)) | |
74 | check_call("tail %s" % self.filename, shell=True) | |
75 | sys.exit(1) | |
99 | def cleanup(self, exc): | |
100 | print(str(exc)) | |
101 | check_call(["tail", self.filename]) | |
102 | sys.exit(1) | |
76 | 103 | |
77 | 104 | def close(self): |
78 | 105 | self.log.close() |
79 | ||
80 | ||
81 | class Executor(object): | |
82 | def do_it(self, cmd, **kwargs): | |
83 | print(cmd) | |
84 | check_call(cmd, shell=True, **kwargs) | |
85 | ||
86 | def close(self): | |
87 | pass | |
88 | 106 | |
89 | 107 | |
90 | 108 | def fmod(n, d): |