Skip to content

Commit cfa948b

Browse files
lichingtnirlipo
authored andcommitted
[46] Media download
[46] Media download Enable download of mp4, gif, png Resolves planimation/frontend#46
1 parent c46e50b commit cfa948b

File tree

8 files changed

+514
-31
lines changed

8 files changed

+514
-31
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.idea
2+
.DS_Store
23

34
# Byte-compiled / optimized / DLL files
45
__pycache__/
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
2+
import sys
3+
import os
4+
import json
5+
6+
7+
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
8+
import mp4_exporter
9+
import png_exporter
10+
import gif_exporter
11+
12+
def export_media(vfg_file, format, parameters):
13+
14+
if parameters==None:
15+
raise Exception("Parameters not found")
16+
17+
# Extract parameters
18+
startStep = int(parameters.get("startStep", 0))
19+
stopStep = int(parameters.get("stopStep", 999999))
20+
# speed = parameters.get("speed", 0)
21+
quality = parameters.get("quality", "high")
22+
23+
# Ensure start and stop are not negative
24+
startStep = max(0, startStep)
25+
stopStep = max(startStep, stopStep)
26+
if quality not in ["low", "medium", "high"]:
27+
quality = "high"
28+
29+
if format == "mp4":
30+
return mp4_exporter.create_MP4(vfg_file, startStep, stopStep, quality)
31+
elif format == "png":
32+
return png_exporter.create_PNGs(vfg_file, startStep, stopStep)
33+
elif format == "gif":
34+
return gif_exporter.create_GIF(vfg_file, startStep, stopStep, quality)
35+
elif format == "webm":
36+
return "error"
37+
return "error"
38+
+166
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import json
2+
import matplotlib.pyplot as plt
3+
import matplotlib.patches as patches
4+
from matplotlib.animation import FuncAnimation
5+
from matplotlib.animation import PillowWriter
6+
# from matplotlib.animation import ImageMagickWriter
7+
from PIL import Image
8+
from io import BytesIO
9+
import base64
10+
import numpy as np
11+
import sys
12+
import os
13+
import tempfile
14+
15+
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
16+
17+
# GIF parameters
18+
_STATES_PER_SECOND = 1.5 # Speed
19+
_START_DEFAULT = 0
20+
_END_DEFAULT = 9999999
21+
QUALITY_SETTINGS = {
22+
"low": {"interp_frames": 2},
23+
"medium": {"interp_frames": 6},
24+
"high": {"interp_frames": 10}
25+
}
26+
27+
def apply_tint(image, color):
28+
r, g, b, a = np.rollaxis(image, axis=-1)
29+
r = np.clip(np.uint8(r * color[0]), 0, 255)
30+
g = np.clip(np.uint8(g * color[1]), 0, 255)
31+
b = np.clip(np.uint8(b * color[2]), 0, 255)
32+
tinted_image = np.stack([r, g, b, a], axis=-1)
33+
return tinted_image
34+
35+
36+
# This class is used due to a bug in PillowWriter where the output gif does not loop
37+
# More info can be found here: https://stackoverflow.com/questions/51512141/how-to-make-matplotlib-saved-gif-looping
38+
class LoopingPillowWriter(PillowWriter):
39+
def finish(self):
40+
self._frames[0].save(
41+
self._outfile, save_all=True, append_images=self._frames[1:],
42+
duration=int(1000 / self.fps), loop=0)
43+
44+
45+
46+
def create_GIF(vfg_file, startStep=_START_DEFAULT, stopStep=_END_DEFAULT, quality='high'):
47+
48+
# Choose the settings based on the quality provided
49+
chosen_quality = QUALITY_SETTINGS.get(quality, QUALITY_SETTINGS["high"])
50+
num_interpolation_frames = chosen_quality["interp_frames"]
51+
52+
with open(vfg_file, 'r') as f:
53+
data = json.load(f)
54+
55+
startStep = max(0, min(startStep, len(data['visualStages']) - 1))
56+
stopStep = max(startStep, min(stopStep, len(data['visualStages']) - 1))
57+
visual_stages = data['visualStages'][startStep:stopStep+1]
58+
59+
fig, ax = plt.subplots()
60+
ax.axis('equal')
61+
ax.axis('off')
62+
63+
max_x = max(max(s['x'] + s['width'] for s in stage['visualSprites']) for stage in data['visualStages'])
64+
max_y = max(max(s['y'] + s['height'] for s in stage['visualSprites']) for stage in data['visualStages'])
65+
66+
ax.set_xlim([0, max_x])
67+
ax.set_ylim([0, max_y])
68+
69+
sprites = {}
70+
last_positions = {}
71+
image_table = {}
72+
tint_cache = {}
73+
74+
image_keys = data.get("imageTable", {}).get("m_keys", [])
75+
image_values = data.get("imageTable", {}).get("m_values", [])
76+
77+
for key, base64_str in zip(image_keys, image_values):
78+
try:
79+
image_data = base64.b64decode(base64_str)
80+
image = Image.open(BytesIO(image_data))
81+
image = image.convert("RGBA")
82+
image_table[key] = np.array(image)
83+
except:
84+
print(f"Warning: Skipping '{key}' because its base64 value could not be decoded.")
85+
continue
86+
87+
def update(frame):
88+
ax.clear()
89+
ax.axis('equal')
90+
ax.axis('off')
91+
ax.set_xlim([0, max_x])
92+
ax.set_ylim([0, max_y])
93+
94+
# Determine whether we're in the pause frames
95+
active_frames = len(visual_stages) * num_interpolation_frames
96+
if frame >= active_frames:
97+
stage = visual_stages[-1] # Use the last visual stage
98+
interpolation_alpha = 1 # No need to interpolate for the pause frames
99+
else:
100+
interpolation_alpha = frame % num_interpolation_frames / (num_interpolation_frames - 1)
101+
stage_idx = frame // num_interpolation_frames
102+
stage = visual_stages[stage_idx]
103+
104+
for sprite in stage['visualSprites']:
105+
x, y, w, h = sprite['x'], sprite['y'], sprite['width'], sprite['height']
106+
color = (sprite['color']['r'], sprite['color']['g'], sprite['color']['b'], sprite['color']['a'])
107+
name = sprite['name']
108+
109+
if name in last_positions:
110+
x_last, y_last = last_positions[name]
111+
x = np.interp(interpolation_alpha, [0, 1], [x_last, x])
112+
y = np.interp(interpolation_alpha, [0, 1], [y_last, y])
113+
114+
prefab_image = sprite.get('prefabimage', None)
115+
116+
if prefab_image in image_table:
117+
cache_key = (prefab_image, tuple(color)) # Create a unique key for this image and color
118+
119+
# Check if the tinted image is in the cache
120+
if cache_key not in tint_cache:
121+
image = np.array(image_table[prefab_image])
122+
tinted_image = apply_tint(image, color)
123+
124+
# Save the tinted image to the cache
125+
tint_cache[cache_key] = tinted_image
126+
127+
# Use the tinted image from the cache
128+
ax.imshow(tint_cache[cache_key], extent=[x, x+w, y, y+h], origin='upper')
129+
130+
else:
131+
if name not in sprites:
132+
sprites[name] = patches.Rectangle((x, y), w, h, linewidth=1, edgecolor='none', facecolor=color)
133+
ax.add_patch(sprites[name])
134+
else:
135+
sprites[name].set_xy((x, y))
136+
sprites[name].set_facecolor(color)
137+
ax.add_patch(sprites[name])
138+
139+
last_positions[name] = (x, y)
140+
141+
if sprite['showname']:
142+
ax.text(x + w/2, y + h/2, name, ha='center', va='center')
143+
144+
# add a few frames to ensure we can view the final state
145+
total_frames = len(visual_stages) * num_interpolation_frames + int(1 * num_interpolation_frames)
146+
fps = int(num_interpolation_frames * _STATES_PER_SECOND)
147+
148+
ani = FuncAnimation(fig, update, frames=total_frames, repeat=True)
149+
150+
# Create a tempFile, as FunctionAnimation cannot write to a BytesIO object (in-memory) and can only write to disk
151+
with tempfile.NamedTemporaryFile(delete=False, suffix=".gif") as tmpfile:
152+
tmpname = tmpfile.name
153+
154+
writer = LoopingPillowWriter(fps=fps)
155+
ani.save(tmpname, writer=writer)
156+
157+
# Read the content of the temporary file into a BytesIO object
158+
output = BytesIO()
159+
with open(tmpname, "rb") as f:
160+
output.write(f.read())
161+
output.seek(0)
162+
163+
# Remove the temporary file
164+
os.remove(tmpname)
165+
166+
return output
+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import json
2+
import matplotlib.pyplot as plt
3+
import matplotlib.patches as patches
4+
from matplotlib.animation import FuncAnimation
5+
from matplotlib.animation import FFMpegWriter
6+
from PIL import Image
7+
from io import BytesIO
8+
import base64
9+
import numpy as np
10+
import sys
11+
import os
12+
import tempfile
13+
14+
15+
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
16+
17+
18+
# MP4 parameters
19+
_STATES_PER_SECOND = 1.5 # Speed
20+
_START_DEFAULT = 0
21+
_END_DEFAULT = 9999999
22+
QUALITY_SETTINGS = {
23+
"low": {"interp_frames": 2},
24+
"medium": {"interp_frames": 6},
25+
"high": {"interp_frames": 10}
26+
}
27+
28+
def apply_tint(image, color):
29+
r, g, b, a = np.rollaxis(image, axis=-1)
30+
r = np.clip(np.uint8(r * color[0]), 0, 255)
31+
g = np.clip(np.uint8(g * color[1]), 0, 255)
32+
b = np.clip(np.uint8(b * color[2]), 0, 255)
33+
tinted_image = np.stack([r, g, b, a], axis=-1)
34+
return tinted_image
35+
36+
37+
38+
# TODO: Parameters: fps, frames per state change, state range, quality
39+
40+
def create_MP4(vfg_file, startStep=_START_DEFAULT, stopStep=_END_DEFAULT, quality='high'):
41+
42+
# Choose the settings based on the quality provided
43+
chosen_quality = QUALITY_SETTINGS.get(quality, QUALITY_SETTINGS["high"])
44+
num_interpolation_frames = chosen_quality["interp_frames"]
45+
46+
with open(vfg_file, 'r') as f:
47+
data = json.load(f)
48+
49+
startStep = max(0, min(startStep, len(data['visualStages']) - 1))
50+
stopStep = max(startStep, min(stopStep, len(data['visualStages']) - 1))
51+
visual_stages = data['visualStages'][startStep:stopStep+1]
52+
53+
fig, ax = plt.subplots()
54+
ax.axis('equal')
55+
ax.axis('off')
56+
57+
max_x = max(max(s['x'] + s['width'] for s in stage['visualSprites']) for stage in data['visualStages'])
58+
max_y = max(max(s['y'] + s['height'] for s in stage['visualSprites']) for stage in data['visualStages'])
59+
60+
ax.set_xlim([0, max_x])
61+
ax.set_ylim([0, max_y])
62+
63+
sprites = {}
64+
last_positions = {}
65+
image_table = {}
66+
tint_cache = {}
67+
68+
image_keys = data.get("imageTable", {}).get("m_keys", [])
69+
image_values = data.get("imageTable", {}).get("m_values", [])
70+
71+
for key, base64_str in zip(image_keys, image_values):
72+
try:
73+
image_data = base64.b64decode(base64_str)
74+
image = Image.open(BytesIO(image_data))
75+
image = image.convert("RGBA")
76+
image_table[key] = np.array(image)
77+
except:
78+
print(f"Warning: Skipping '{key}' because its base64 value could not be decoded.")
79+
continue
80+
81+
def update(frame):
82+
ax.clear()
83+
ax.axis('equal')
84+
ax.axis('off')
85+
ax.set_xlim([0, max_x])
86+
ax.set_ylim([0, max_y])
87+
88+
# Determine whether we're in the pause frames
89+
active_frames = len(visual_stages) * num_interpolation_frames
90+
if frame >= active_frames:
91+
stage = visual_stages[-1] # Use the last visual stage
92+
interpolation_alpha = 1 # No need to interpolate for the pause frames
93+
else:
94+
interpolation_alpha = frame % num_interpolation_frames / (num_interpolation_frames - 1)
95+
stage_idx = frame // num_interpolation_frames
96+
stage = visual_stages[stage_idx]
97+
98+
for sprite in stage['visualSprites']:
99+
x, y, w, h = sprite['x'], sprite['y'], sprite['width'], sprite['height']
100+
color = (sprite['color']['r'], sprite['color']['g'], sprite['color']['b'], sprite['color']['a'])
101+
name = sprite['name']
102+
103+
if name in last_positions:
104+
x_last, y_last = last_positions[name]
105+
x = np.interp(interpolation_alpha, [0, 1], [x_last, x])
106+
y = np.interp(interpolation_alpha, [0, 1], [y_last, y])
107+
108+
prefab_image = sprite.get('prefabimage', None)
109+
110+
if prefab_image in image_table:
111+
cache_key = (prefab_image, tuple(color)) # Create a unique key for this image and color
112+
113+
# Check if the tinted image is in the cache
114+
if cache_key not in tint_cache:
115+
image = np.array(image_table[prefab_image])
116+
tinted_image = apply_tint(image, color)
117+
118+
# Save the tinted image to the cache
119+
tint_cache[cache_key] = tinted_image
120+
121+
# Use the tinted image from the cache
122+
ax.imshow(tint_cache[cache_key], extent=[x, x+w, y, y+h], origin='upper')
123+
124+
else:
125+
if name not in sprites:
126+
sprites[name] = patches.Rectangle((x, y), w, h, linewidth=1, edgecolor='none', facecolor=color)
127+
ax.add_patch(sprites[name])
128+
else:
129+
sprites[name].set_xy((x, y))
130+
sprites[name].set_facecolor(color)
131+
ax.add_patch(sprites[name])
132+
133+
last_positions[name] = (x, y)
134+
135+
if sprite['showname']:
136+
ax.text(x + w/2, y + h/2, name, ha='center', va='center')
137+
138+
# add a few frames to ensure we can view the final state
139+
total_frames = len(visual_stages) * num_interpolation_frames + int(1 * num_interpolation_frames)
140+
fps = int(num_interpolation_frames * _STATES_PER_SECOND)
141+
142+
ani = FuncAnimation(fig, update, frames=total_frames, repeat=False)
143+
144+
# Create a tempFile, as FunctionAnimation cannot write to a BytesIO object (in-memory) and can only write to disk
145+
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmpfile:
146+
tmpname = tmpfile.name
147+
148+
writer = FFMpegWriter(fps=fps, bitrate=1800)
149+
ani.save(tmpname, writer=writer)
150+
151+
# Read the content of the temporary file into a BytesIO object
152+
output = BytesIO()
153+
with open(tmpname, "rb") as f:
154+
output.write(f.read())
155+
output.seek(0)
156+
157+
# Remove the temporary file
158+
os.remove(tmpname)
159+
160+
return output
161+

0 commit comments

Comments
 (0)