git @ Cat's Eye Technologies kinoje / 5c5d2df
Merge pull request #1 from catseye/develop-0.2 Develop 0.2 Chris Pressey authored 4 years ago GitHub committed 4 years ago
13 changed file(s) with 347 addition(s) and 235 deletion(s). Raw diff Collapse all Expand all
00 kinoje
11 ======
22
3 *Version 0.1. Subject to backwards-incompatible changes without notice.*
3 *Version 0.2. Subject to backwards-incompatible changes without notice.*
44
55 **kinoje** is a templating-based animation tool. A provided template is filled out once for each
66 frame of the animation; the result of the template expansion is used to create a still image; and
3232
3333 You can also ask it to create a GIF by specifying an output filename with that as its file extension:
3434
35 bin/kinoje eg/squares.yaml -o squares.gif --duration=2.0
35 bin/kinoje eg/squares.yaml -o squares.gif
36
37 Theory of Operation
38 -------------------
39
40 The `kinoje` executable actually calls 3 other executables:
41
42 * `kinoje-expand` fills out the template multiple times, once for each frame of the movie, and
43 saves the expanded templates (which we call "instants") into a directory.
44 * `kinoje-render` takes a directory of instants and creates a set of images, one for each instant,
45 in another directory. It calls a rendering command specified in the config to do this.
46 * `kinoje-compile` takes a directory of images and turns them into a movie file (`.mp4` or `.gif`).
47
48 These executables can also be called directly, if you e.g. already have a directory of instants
49 you want to render and compile into a final movie.
3650
3751 File Format
3852 -----------
3953
40 The input YAML file must contain, at the minimum, a key called `template` giving a string (typically
41 a multi-line string) in Jinja2 syntax, which will be filled out once for each frame.
54 The input config file must contain, at the minimum, a key called `template` containing the template,
55 and a key `duration` which specifies the duration in seconds of the movie. It may also contain
56 the following keys, or if not, the following defaults will be used:
4257
43 The context with which it will be filled out is constructed as follows:
58 * `start`: 0.0
59 * `stop`: 1.0
60 * `fps`: 25.0
61 * `width`: 320
62 * `height`: 200
63
64 The template is typically a multi-line string in Jinja2 syntax, which will be filled out once for
65 each frame. The context with which it will be filled out is constructed as follows:
4466
4567 * `t` is a floating point value which will vary from 0.0 on the first frame to 1.0 on the last
4668 frame. It is this value which will typically drive the animation. For example, if the animation
44
55 sys.path.insert(0, join(dirname(realpath(sys.argv[0])), '..', 'src'))
66
7 from kinoje import main
7 from kinoje import orchestrator
88
99
1010 if __name__ == '__main__':
11 main.main()
11 orchestrator.main()
0 #!/usr/bin/env python
1
2 from os.path import realpath, dirname, join
3 import sys
4
5 sys.path.insert(0, join(dirname(realpath(sys.argv[0])), '..', 'src'))
6
7 from kinoje import compiler
8
9
10 if __name__ == '__main__':
11 compiler.main()
0 #!/usr/bin/env python
1
2 from os.path import realpath, dirname, join
3 import sys
4
5 sys.path.insert(0, join(dirname(realpath(sys.argv[0])), '..', 'src'))
6
7 from kinoje import expander
8
9
10 if __name__ == '__main__':
11 expander.main()
0 #!/usr/bin/env python
1
2 from os.path import realpath, dirname, join
3 import sys
4
5 sys.path.insert(0, join(dirname(realpath(sys.argv[0])), '..', 'src'))
6
7 from kinoje import renderer
8
9
10 if __name__ == '__main__':
11 renderer.main()
00 duration: 1.44
1 command_template: povray +L{libdir} -D +I{infile} +O{outfile} +W{width} +H{height} +A
12 template: |-
23 global_settings { assumed_gamma 2.2 }
34
0 type: svg
0 duration: 2.0
1 width: 400
2 height: 400
3 command_template: inkscape -z -e {outfile} -w {width} -h {height} {infile}
14 template: |-
25 <?xml version="1.0" standalone="no"?>
36 <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
0 from argparse import ArgumentParser
01 import os
2 import sys
3
4 from kinoje.utils import LoggingExecutor, load_config_file
5
6
7 SUPPORTED_OUTPUT_FORMATS = ('.m4v', '.mp4', '.gif')
18
29
310 class Compiler(object):
4 def __init__(self, dirname, outfilename, options, exe):
11 def __init__(self, dirname, outfilename, config, exe):
512 self.dirname = dirname
6 self.options = options
713 self.exe = exe
814 self.outfilename = outfilename
15 self.config = config
16 self.frame_fmt = "%08d.png"
917
1018
1119 class GifCompiler(Compiler):
1220
1321 def compile(self, num_frames):
1422 # TODO: show some warning if this is not an integer delay
15 delay = int(100.0 / self.options.fps)
23 delay = int(100.0 / self.config['fps'])
1624
17 filenames = [os.path.join(self.dirname, self.options.frame_fmt % f) for f in xrange(0, num_frames)]
18 if self.options.shorten_final_frame:
25 filenames = [os.path.join(self.dirname, self.frame_fmt % f) for f in xrange(0, num_frames)]
26 if self.config.get('shorten_final_frame'):
1927 filespec = ' '.join(filenames[:-1] + ['-delay', str(delay / 2), filenames[-1]])
2028 else:
2129 filespec = ' '.join(filenames)
3341 class MpegCompiler(Compiler):
3442
3543 def compile(self, num_frames):
36 ifmt = os.path.join(self.dirname, self.options.frame_fmt)
44 ifmt = os.path.join(self.dirname, self.frame_fmt)
3745 # fun fact: even if you say -r 30, ffmpeg still picks 25 fps
3846 cmd = "ffmpeg -i %s -c:v libx264 -profile:v baseline -pix_fmt yuv420p -r %s -y %s" % (
39 ifmt, int(self.options.fps), self.outfilename
47 ifmt, int(self.config['fps']), self.outfilename
4048 )
4149 self.exe.do_it(cmd)
4250
4351 def view(self):
4452 self.exe.do_it("vlc %s" % self.outfilename)
53
54
55 def main():
56 argparser = ArgumentParser()
57
58 argparser.add_argument('configfile', metavar='FILENAME', type=str,
59 help='Configuration file containing the template and parameters'
60 )
61 argparser.add_argument('framesdir', metavar='DIRNAME', type=str,
62 help='Directory that will be populated with image of each single frame'
63 )
64 argparser.add_argument('output', metavar='FILENAME', type=str,
65 help='The movie file to create. The extension of this filename '
66 'determines the output format and must be one of %r. '
67 'If not given, a default name will be chosen based on the '
68 'configuration filename with a .mp4 extension added.' % (SUPPORTED_OUTPUT_FORMATS,)
69 )
70
71 argparser.add_argument("--shorten-final-frame", default=False, action='store_true',
72 help="Make the last frame in a GIF animation delay only half as long. "
73 "Might make looping smoother when uploaded to Twitter. YMMV."
74 )
75 argparser.add_argument("--view", default=False, action='store_true',
76 help="Display the resultant movie."
77 )
78
79 options = argparser.parse_args(sys.argv[1:])
80
81 (whatever, outext) = os.path.splitext(options.output)
82 if outext not in SUPPORTED_OUTPUT_FORMATS:
83 raise ValueError("%s not a supported output format (%r)" % (outext, SUPPORTED_OUTPUT_FORMATS))
84
85 config = load_config_file(options.configfile)
86 config['shorten_final_frame'] = options.shorten_final_frame
87
88 exe = LoggingExecutor('compiler.log')
89
90 compiler = {
91 '.gif': GifCompiler,
92 '.mp4': MpegCompiler,
93 '.m4v': MpegCompiler,
94 }[outext](options.framesdir, options.output, config, exe)
95
96 compiler.compile(config['num_frames'])
97
98 if options.view:
99 compiler.view()
100
101 exe.close()
0 from argparse import ArgumentParser
1 from copy import copy
2 import math
3 import os
4 import sys
5
6 from jinja2 import Template
7
8 from kinoje.utils import LoggingExecutor, fmod, tween, load_config_file
9
10
11 class Expander(object):
12 """Takes a directory and a template (Jinja2) and expands the template a number of times,
13 creating a number of filled-out text files in the directory."""
14 def __init__(self, dirname, template, config, exe):
15 self.dirname = dirname
16 self.template = template
17 self.config = config
18 self.exe = exe
19
20 self.fun_context = {}
21 for key, value in self.config.get('functions', {}).iteritems():
22 self.fun_context[key] = eval("lambda x: " + value)
23
24 def fillout_template(self, frame, t):
25 context = copy(self.config)
26 context.update(self.fun_context)
27 context.update({
28 'width': float(self.config.get('width', 320.0)),
29 'height': float(self.config.get('height', 200.0)),
30 't': t,
31 'math': math,
32 'tween': tween,
33 'fmod': fmod,
34 })
35 output_filename = os.path.join(self.dirname, "%08d.txt" % frame)
36 with open(output_filename, 'w') as f:
37 f.write(self.template.render(context))
38
39
40 def main():
41 argparser = ArgumentParser()
42
43 argparser.add_argument('configfile', metavar='FILENAME', type=str,
44 help='Configuration file containing the template and parameters'
45 )
46 argparser.add_argument('instantsdir', metavar='DIRNAME', type=str,
47 help='Directory that will be populated with instants (text files describing frames)'
48 )
49
50 options = argparser.parse_args(sys.argv[1:])
51
52 config = load_config_file(options.configfile)
53 template = Template(config['template'])
54
55 exe = LoggingExecutor('movie.log')
56
57 expander = Expander(options.instantsdir, template, config, exe)
58
59 t = config['start']
60 t_step = config['t_step']
61 for frame in xrange(config['num_frames']):
62 expander.fillout_template(frame, t)
63 t += t_step
64
65 exe.close()
+0
-190
src/kinoje/main.py less more
0 """\
1 kinoje {options} input-file.yaml output-file.{gif,m4v,mp4}
2
3 Create a movie file from the template and configuration in the given YAML file."""
4
5 # Note: just about everything here is subject to change!
6
7 from datetime import datetime, timedelta
8 from argparse import ArgumentParser
9 import os
10 import re
11 import sys
12 from tempfile import mkdtemp
13
14 from jinja2 import Template
15 import yaml
16 try:
17 from yaml import CLoader as Loader
18 except ImportError:
19 from yaml import Loader
20
21 from kinoje.utils import LoggingExecutor
22 from kinoje.renderer import Renderer
23 from kinoje.compiler import GifCompiler, MpegCompiler
24
25
26 SUPPORTED_OUTPUT_FORMATS = ('.m4v', '.mp4', '.gif')
27
28
29 def main():
30 argparser = ArgumentParser()
31
32 argparser.add_argument('configfile', metavar='FILENAME', type=str,
33 help='A YAML file containing the template to render for each frame, '
34 'as well as configuration for rendering the template.'
35 )
36 argparser.add_argument('-o', '--output', metavar='FILENAME', type=str, default=None,
37 help='The movie file to create. The extension of this filename '
38 'determines the output format and must be one of %r. '
39 'If not given, a default name will be chosen based on the '
40 'configuration filename with a .mp4 extension added.' % (SUPPORTED_OUTPUT_FORMATS,)
41 )
42
43 argparser.add_argument("--width", default=320, type=int)
44 argparser.add_argument("--height", default=200, type=int)
45
46 argparser.add_argument("--size", default=None, type=str,
47 help=''
48 )
49
50 argparser.add_argument("--square", default=False, action='store_true')
51
52 argparser.add_argument("--start", default=0.0, type=float, metavar='INSTANT',
53 help="t-value at which to start rendering the movie. Default=0.0"
54 )
55 argparser.add_argument("--stop", default=1.0, type=float, metavar='INSTANT',
56 help="t-value at which to stop rendering the movie. Default=1.0"
57 )
58 argparser.add_argument("--duration", default=None, type=float, metavar='SECONDS',
59 help="Override the duration specified in the configuration."
60 )
61
62 argparser.add_argument("--fps", default=25.0, type=float, metavar='FPS',
63 help="The number of frames to render for each second. Note that the "
64 "tool that makes a movie file from images might not honour this value exactly."
65 )
66 argparser.add_argument("--frame-fmt", default="out%05d.png", type=str)
67
68 argparser.add_argument("--still", default=None, type=float, metavar='INSTANT',
69 help="If given, generate only a single frame (at the specified instant "
70 "betwen 0.0 and 1.0) and display it using eog, instead of building "
71 "the whole movie."
72 )
73 argparser.add_argument("--view", default=False, action='store_true')
74 argparser.add_argument("--shorten-final-frame", default=False, action='store_true',
75 help="Make the last frame in a GIF animation delay only half as long. "
76 "Might make looping smoother when uploaded to Twitter. YMMV."
77 )
78
79 argparser.add_argument("--config", default=None, type=str)
80
81 options = argparser.parse_args(sys.argv[1:])
82
83 options.width, options.height = {
84 'tiny': (160, 100),
85 'small': (320, 200),
86 'big': (640, 400),
87 'huge': (800, 600),
88 'giant': (1280, 800),
89 }[options.size] if options.size is not None else (options.width, options.height)
90
91 if options.square:
92 options.height = options.width
93
94 if options.still is not None:
95 options.duration = 1.0
96 options.start = options.still
97
98 infilename = options.configfile
99 outfilename = options.output
100 if options.output is None:
101 (inbase, inext) = os.path.splitext(os.path.basename(infilename))
102 outfilename = inbase + '.mp4'
103 (whatever, outext) = os.path.splitext(outfilename)
104 if outext not in SUPPORTED_OUTPUT_FORMATS:
105 raise ValueError("%s not a supported output format (%r)" % (outext, SUPPORTED_OUTPUT_FORMATS))
106
107 with open(infilename, 'r') as file_:
108 config = yaml.load(file_, Loader=Loader)
109
110 if options.config is not None:
111 settings = {}
112 for setting_string in options.config.split(','):
113 key, value = setting_string.split(':')
114 if re.match(r'^\d*\.?\d*$', value):
115 value = float(value)
116 settings[key] = value
117 config.update(settings)
118
119 template = Template(config['template'])
120
121 tempdir = mkdtemp()
122
123 duration = options.duration
124 if duration is None:
125 duration = config['duration']
126
127 if 'render_command_template' not in config:
128 render_type = config.get('type', 'povray')
129 if render_type == 'povray':
130 config['render_command_template'] = "povray +L{indir} -D +I{infile} +O{outfile} +W{width} +H{height} +A"
131 elif render_type == 'svg':
132 config['render_command_template'] = "inkscape -z -e {outfile} -w {width} -h {height} {infile}"
133 else:
134 raise NotImplementedError
135
136 start_time = options.start * duration
137 stop_time = options.stop * duration
138 requested_duration = stop_time - start_time
139 num_frames = int(requested_duration * options.fps)
140 t_step = 1.0 / (duration * options.fps)
141
142 print "Start time: t=%s, %s seconds" % (options.start, start_time)
143 print "Stop time: t=%s, %s seconds" % (options.stop, stop_time)
144 print "Requested duration: %s seconds" % requested_duration
145 print "Frame rate: %s fps" % options.fps
146 print "Number of frames: %s (rounded to %s)" % (requested_duration * options.fps, num_frames)
147 print "t-Step: %s" % t_step
148
149 exe = LoggingExecutor(os.path.join(tempdir, 'movie.log'))
150 t = options.start
151
152 started_at = datetime.now()
153
154 renderer = Renderer(tempdir, template, config, options, exe)
155
156 for frame in xrange(num_frames):
157
158 elapsed = (datetime.now() - started_at).total_seconds()
159 eta = '???'
160 if frame > 0:
161 seconds_per_frame = elapsed / float(frame)
162 eta = started_at + timedelta(seconds=num_frames * seconds_per_frame)
163
164 print "t=%s (%s%% done, eta %s)" % (t, int(((t - options.start) / options.stop) * 100), eta)
165
166 fn = renderer.render_frame(frame, t)
167
168 t += t_step
169
170 if options.still is not None:
171 exe.do_it("eog %s" % fn)
172 sys.exit(0)
173
174 compiler = {
175 '.gif': GifCompiler,
176 '.mp4': MpegCompiler,
177 '.m4v': MpegCompiler,
178 }[outext](tempdir, outfilename, options, exe)
179
180 compiler.compile(num_frames)
181 finished_at = datetime.now()
182
183 if options.view:
184 compiler.view()
185
186 exe.close()
187
188 run_duration = finished_at - started_at
189 print "Finished, took %s seconds" % run_duration.total_seconds()
0 from datetime import datetime, timedelta
1 from argparse import ArgumentParser
2 import os
3 import re
4 import sys
5 from tempfile import mkdtemp
6
7 from kinoje.utils import Executor, load_config_file
8
9
10 SUPPORTED_OUTPUT_FORMATS = ('.m4v', '.mp4', '.gif')
11
12
13 def main():
14 argparser = ArgumentParser()
15
16 argparser.add_argument('configfile', metavar='FILENAME', type=str,
17 help='A YAML file containing the template to render for each frame, '
18 'as well as configuration for rendering the template.'
19 )
20 argparser.add_argument('-o', '--output', metavar='FILENAME', type=str, default=None,
21 help='The movie file to create. The extension of this filename '
22 'determines the output format and must be one of %r. '
23 'If not given, a default name will be chosen based on the '
24 'configuration filename with a .mp4 extension added.' % (SUPPORTED_OUTPUT_FORMATS,)
25 )
26
27 options, unknown = argparser.parse_known_args(sys.argv[1:])
28 remainder = ' '.join(unknown)
29
30 if options.output is None:
31 (configbase, configext) = os.path.splitext(os.path.basename(options.configfile))
32 output_filename = configbase + '.mp4'
33 else:
34 output_filename = options.output
35
36 exe = Executor()
37
38 instants_dir = mkdtemp()
39 frames_dir = mkdtemp()
40
41 exe.do_it("kinoje-expand {} {}".format(options.configfile, instants_dir))
42 exe.do_it("kinoje-render {} {} {}".format(options.configfile, instants_dir, frames_dir))
43 exe.do_it("kinoje-compile {} {} {} {}".format(options.configfile, frames_dir, output_filename, remainder))
44
45 exe.close()
0 from copy import copy
1 import math
0 from argparse import ArgumentParser
1 import re
22 import os
3 import sys
34
4 from kinoje.utils import fmod, tween
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 LoggingExecutor, load_config_file
512
613
714 class Renderer(object):
8 def __init__(self, dirname, template, config, options, exe):
9 self.dirname = dirname
10 self.template = template
11 self.config = config
12 self.options = options
15 """Takes a source directory filled with text files and a destination directory and
16 creates one image file in the destination directory from each text file in the source."""
17 def __init__(self, config, src, dest, exe):
18 self.command_template = config['command_template']
19 self.libdir = config['libdir']
20 self.src = src
21 self.dest = dest
1322 self.exe = exe
23 self.width = config['width']
24 self.height = config['height']
1425
15 self.fun_context = {}
16 for key, value in self.config.get('functions', {}).iteritems():
17 self.fun_context[key] = eval("lambda x: " + value)
26 def render_all(self):
27 for filename in sorted(os.listdir(self.src)):
28 full_srcname = os.path.join(self.src, filename)
29 match = re.match(r'^.*?(\d+).*?$', filename)
30 frame = int(match.group(1))
31 destname = "%08d.png" % frame
32 full_destname = os.path.join(self.dest, destname)
33 self.render(frame, full_srcname, full_destname)
1834
19 def render_frame(self, frame, t):
20 out_pov = os.path.join(self.dirname, 'out.pov')
21 context = copy(self.config)
22 context.update(self.fun_context)
23 context.update({
24 'width': float(self.options.width),
25 'height': float(self.options.height),
26 't': t,
27 'math': math,
28 'tween': tween,
29 'fmod': fmod,
30 })
31 with open(out_pov, 'w') as f:
32 f.write(self.template.render(context))
33 fn = os.path.join(self.dirname, self.options.frame_fmt % frame)
34 cmd = self.config['render_command_template'].format(
35 infile=out_pov, indir=os.path.dirname(self.options.configfile), outfile=fn,
36 width=self.options.width, height=self.options.height
35 def render(self, frame, full_srcname, full_destname):
36 cmd = self.command_template.format(
37 infile=full_srcname,
38 libdir=self.libdir,
39 outfile=full_destname,
40 width=self.width,
41 height=self.height
3742 )
3843 self.exe.do_it(cmd)
39 return fn
44
45
46 def main():
47 argparser = ArgumentParser()
48
49 argparser.add_argument('configfile', metavar='FILENAME', type=str,
50 help='Configuration file containing the template and parameters'
51 )
52 argparser.add_argument('instantsdir', metavar='DIRNAME', type=str,
53 help='Directory containing instants (text file descriptions of each single frame) to render'
54 )
55 argparser.add_argument('framesdir', metavar='DIRNAME', type=str,
56 help='Directory that will be populated with images, one for each frame'
57 )
58
59 options = argparser.parse_args(sys.argv[1:])
60
61 config = load_config_file(options.configfile)
62
63 exe = LoggingExecutor('renderer.log')
64
65 renderer = Renderer(config, options.instantsdir, options.framesdir, exe)
66 renderer.render_all()
67
68 exe.close()
0 import os
01 import sys
12 from subprocess import check_call
3
4
5 def load_config_file(filename):
6 import yaml
7 try:
8 from yaml import CLoader as Loader
9 except ImportError:
10 from yaml import Loader
11
12 with open(filename, 'r') as file_:
13 config = yaml.load(file_, Loader=Loader)
14
15 config['libdir'] = os.path.dirname(filename)
16
17 config['start'] = float(config.get('start', 0.0))
18 config['stop'] = float(config.get('stop', 1.0))
19 config['fps'] = float(config.get('fps', 25.0))
20 config['width'] = float(config.get('width', 320.0))
21 config['height'] = float(config.get('height', 200.0))
22
23 duration = config['duration']
24 start = config['start']
25 stop = config['stop']
26 fps = config['fps']
27
28 config['start_time'] = start * duration
29 config['stop_time'] = stop * duration
30 config['requested_duration'] = config['stop_time'] - config['start_time']
31 config['num_frames'] = int(config['requested_duration'] * fps)
32 config['t_step'] = 1.0 / (duration * fps)
33
34 return config
235
336
437 class LoggingExecutor(object):
1851
1952 def close(self):
2053 self.log.close()
54
55
56 class Executor(object):
57 def do_it(self, cmd, **kwargs):
58 print cmd
59 check_call(cmd, shell=True, **kwargs)
60
61 def close(self):
62 pass
2163
2264
2365 def fmod(n, d):