6
6
"""
7
7
import subprocess
8
8
import argparse
9
+ import math
9
10
import os
10
11
11
12
################################################################################
12
13
14
+ class ColorInterpolator :
15
+ "Linear interpolation between two sRGB colors."
16
+
17
+ def __init__ (self , rgb0 , rgb1 , t0 = 1.0 , t1 = None , power = 1.0 ):
18
+ """
19
+ Initialize the generator.
20
+ - rgb0: 3-tuple of 8-bit sRGB colors that represent the start time
21
+ - rgb1: 3-tuple of 8-bit sRGB colors that represent the end time
22
+ - t0: start time (if t1 is omitted, the end time, and start is 0)
23
+ - t1: end time
24
+ - power: power to apply to the interpolation coefficient;
25
+ if negative, the power will be applied to *reversed* time
26
+ """
27
+ self .rgb0 = rgb0
28
+ self .rgb1 = rgb1
29
+ if t1 is None :
30
+ t0 , t1 = 0 , t0
31
+ self .t0 , self .t1 = t0 , t1
32
+ self .dt = float (t1 - t0 )
33
+ self .power = power
34
+
35
+ def __call__ (self , t ):
36
+ "Generate a color for a specified time."
37
+ if t <= self .t0 : return self .rgb0
38
+ if t >= self .t1 : return self .rgb1
39
+ t = (t - self .t0 ) / self .dt
40
+ if self .power < 0 : t = 1.0 - t
41
+ t = math .pow (t , abs (self .power ))
42
+ if self .power < 0 : t = 1.0 - t
43
+ return tuple (max (0 , min (255 , int ((1.0 - t ) * c0 + t * c1 + 0.5 ))) for c0 , c1 in zip (self .rgb0 , self .rgb1 ))
44
+
45
+ ################################################################################
46
+
13
47
class Bitmap :
14
48
"A single still frame."
15
49
16
- def __init__ (self , c1 , c2 = None , init = None , background = None , exterior = None ):
50
+ def __init__ (self , c1 , c2 = None , border = 0 , init = None , background = None , exterior = None ):
17
51
"""
18
52
Initialize an RGB bitmap using one of the following syntaxes:
19
53
- Bitmap(other) => copy everything from other bitmap
54
+ - Bitmap(seq) => auto-detect bounding box from sequence (or dict) of
55
+ 2-tuples or complex numbers
20
56
- Bitmap(width, height) => valid coordinates 0,0 ... width-1, height-1
21
57
- Bitmap((width,height)) => as above
22
- - Bitmap(width+height*1j) => as above
23
58
- Bitmap((x0,y0), (x1,y1)) => valid coordinates x0,y0 ... x1,y1;
24
59
width = x1 - x0 + 1; height = y1 - y0 + 1
25
60
- Bitmap(x0+y0*1j, x1+y1*1j) => as above
26
61
Other parameters:
62
+ - border: number of extra background pixels to add around the edges
63
+ of the image
27
64
- init: bytes, bytearray, list or anything that has a tobytes()
28
65
method, to be used to initialize bitmap with
29
66
- background: 3-tuple of 8-bit sRGB values for background;
@@ -37,21 +74,38 @@ def __init__(self, c1, c2=None, init=None, background=None, exterior=None):
37
74
self .exterior = c1 .exterior
38
75
if c2 and isinstance (c1 , int ) and isinstance (c2 , int ):
39
76
c1 , c2 = (c1 , c2 ), None
77
+ if (c2 is None ) and not (isinstance (c1 , int ) or (isinstance (c1 , tuple ) and (len (c1 ) == 2 ))):
78
+ keys = set (c1 )
79
+ any_item = keys .pop ()
80
+ cplx = isinstance (any , complex )
81
+ keys .add (any_item )
82
+ if cplx :
83
+ xx = {int (p .real ) for p in keys }
84
+ yy = {int (p .imag ) for p in keys }
85
+ else :
86
+ xx = {x for x ,y in keys }
87
+ yy = {y for x ,y in keys }
88
+ c1 = (min (xx ), min (yy ))
89
+ c2 = (max (xx ), max (yy ))
40
90
if isinstance (c1 , complex ):
41
91
c1 = (int (c1 .real ), int (c1 .imag ))
42
92
if isinstance (c2 , complex ):
43
93
c2 = (int (c2 .real ), int (c2 .imag ))
44
94
if c2 is None :
45
95
self .width , self .height = c1
46
- self .x0 , self .y0 = 0 , 0
47
- self .x1 , self .y1 = self .width - 1 , self .height - 1
96
+ self .x0 , self .y0 = border , border
97
+ self .x1 , self .y1 = self .width - 1 + border , self .height - 1 + border
48
98
else :
49
99
self .x0 , self .y0 = c1
50
100
self .x1 , self .y1 = c2
51
101
self .x0 , self .x1 = min (self .x0 , self .x1 ), max (self .x0 , self .x1 )
52
102
self .y0 , self .y1 = min (self .y0 , self .y1 ), max (self .y0 , self .y1 )
53
- self .width = self .x1 - self .x0 + 1
54
- self .height = self .y1 - self .y0 + 1
103
+ self .x0 -= border
104
+ self .y0 -= border
105
+ self .x1 += border
106
+ self .y1 += border
107
+ self .width = self .x1 - self .x0 + 1
108
+ self .height = self .y1 - self .y0 + 1
55
109
if not background :
56
110
background = (0 , 0 , 0 )
57
111
self .exterior = tuple (exterior or background )
@@ -139,15 +193,19 @@ class Visualizer:
139
193
a live preview will be shown instead.
140
194
"""
141
195
142
- def __init__ (self , source = None , extra_args = None , input_arg = False ):
196
+ def __init__ (self , default_zoom = 1 , default_speed = None , source = None , extra_args = None , input_arg = False , title = "Visualization" ):
143
197
"""
144
198
Read command-line options.
199
+ - default_zoom: default zoom factor
200
+ - default_speed: if present, offer a -s/--speed option and set
201
+ frameskip appropriately
145
202
- source: source bitmap (if already known)
146
203
- extra_args: function that is called with the argparse.ArgumentParser
147
204
instance, to add additional arguments
148
205
- input_arg: set to nonzero to include the -i/--input argument;
149
206
if set to a string, this will be the default argument
150
207
(result will be available as Visualizer.input)
208
+ - title: window title
151
209
"""
152
210
parser = argparse .ArgumentParser ()
153
211
parser = argparse .ArgumentParser ()
@@ -156,29 +214,39 @@ def __init__(self, source=None, extra_args=None, input_arg=False):
156
214
help = "input file name" )
157
215
parser .add_argument ("-o" , "--output" , metavar = "VIDEO.mp4" ,
158
216
help = "save result into video file [default: show on screen]" )
159
- parser .add_argument ("-z" , "--zoom" , metavar = "N" , type = int , default = 4 ,
217
+ parser .add_argument ("-z" , "--zoom" , metavar = "N" , type = int , default = default_zoom ,
160
218
help = "scaling factor [integer; default: %(default)s]" )
161
219
parser .add_argument ("-r" , "--fps" , metavar = "FPS" , type = int , default = 30 ,
162
220
help = "video speed in frames per second [default: %(default)s]" )
221
+ if default_speed :
222
+ parser .add_argument ("-s" , "--speed" , metavar = "TPS" , type = float , default = default_speed ,
223
+ help = "simulation speed in ticks per second [default: %(default)s]" )
163
224
if extra_args :
164
225
extra_args (parser )
165
226
parser .add_argument ("-q" , "--crf" , metavar = "CRF" , type = int , default = 26 ,
166
227
help = "x64 CRF quality factor [default: %(default)s]" )
167
228
self .args = parser .parse_args ()
168
229
self .gif = self .args .output and (os .path .splitext (self .args .output )[- 1 ].strip ("." ).lower () == "gif" )
169
- self .source = source
230
+ self .source = self . bmp = source
170
231
self .fps = self .args .fps
232
+ if default_speed :
233
+ self .speed = self .args .speed
234
+ self .frameskip = max (1 , int (self .speed / self .fps + 0.5 ))
235
+ else :
236
+ self .frameskip = 1
171
237
self .input = self .args .input if input_arg else None
172
238
self .proc = None
239
+ self .title = title
173
240
174
- def start (self , source = None , verbose = False ):
241
+ def start (self , source = None , frameskip = 0 , verbose = False ):
175
242
"""
176
243
Start the visualization (or encoding).
177
244
- source: set source bitmap (if not already done so in the constructor)
245
+ - frameskip: override frameskip value that's been set in the constructor
178
246
- verbose: set to True to show the FFmpeg/FFplay command line
179
247
"""
180
248
if source :
181
- self .source = source
249
+ self .source = self . bmp = source
182
250
w , h = getattr (self .source , 'width' , 0 ), getattr (self .source , 'height' , 0 )
183
251
if not (w ) or not (h ):
184
252
w , h = getattr (self .source , 'size' , (0 ,0 ))
@@ -190,7 +258,7 @@ def start(self, source=None, verbose=False):
190
258
if self .args .output : cmdline += ["-i" ]
191
259
cmdline += ["-" , "-vf" , f"scale={ w * self .args .zoom } x{ h * self .args .zoom } :flags=neighbor" ]
192
260
if not self .args .output :
193
- cmdline += ["-window_title" , "AoC Sand Simulation" ]
261
+ cmdline += ["-window_title" , self . title ]
194
262
elif self .gif :
195
263
cmdline [- 1 ] += ",split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=bayer"
196
264
cmdline += ["-f" , "gif" , self .args .output ]
@@ -201,17 +269,36 @@ def start(self, source=None, verbose=False):
201
269
print (os .getenv ("PS4" , "+ " ) + ' ' .join ((f'"{ a } "' if (' ' in a ) else a ) for a in cmdline ))
202
270
self .proc = subprocess .Popen (cmdline , stdin = subprocess .PIPE )
203
271
204
- def write (self , source = None , n = 1 ):
272
+ if frameskip :
273
+ self .frameskip = frameskip
274
+ self .skipcount = self .frameskip
275
+ self .frames = 0
276
+ self .ticks = 0
277
+
278
+ def write (self , source = None , n = 0 , initial = False , final = False ):
205
279
"""
206
280
Send a frame to the display or encoder. start() must have been called
207
281
before that.
208
282
- source: source bitmap (if not already set earlier)
209
- - n: number of times to repeat the frame
283
+ - n: number of times to repeat the frame;
284
+ 0 = automatic (depends on frameskip), larger = explicit count
285
+ - initial: if set to True, override n to 1 (on preview) or fps/2
286
+ (on export), to visualize initial state
287
+ - final: if set to True, override n to fps*2, to visualize final state
210
288
EnvironmentErrors or KeyboardInterrupts may occur; those should be
211
289
intercepted by the host application and be treated as if the output
212
290
finished normally, i.e. stop()/close() should *still* be called.
213
291
"""
214
292
assert self .proc , "no output running"
293
+ if initial : n = max (1 , (self .fps // 2 )) if self .args .output else 1
294
+ if final : n = self .fps * 2
295
+ if not n :
296
+ self .ticks += 1
297
+ self .skipcount -= 1
298
+ if self .skipcount > 0 :
299
+ return
300
+ self .skipcount = self .frameskip
301
+ n = 1
215
302
if not source :
216
303
source = self .source
217
304
if hasattr (source , 'tofile' ):
@@ -221,6 +308,7 @@ def write(self, source=None, n=1):
221
308
frame = source .tobytes ()
222
309
for i in range (n ):
223
310
self .proc .stdin .write (frame )
311
+ self .frames += n
224
312
225
313
def stop (self ):
226
314
"Stop the display or encoder."
0 commit comments