Skip to content

Commit 5e6853f

Browse files
authored
Merge pull request #1 from cyberbotics/develop
Initial code
2 parents e3b146b + 3eac2c0 commit 5e6853f

File tree

11 files changed

+403
-0
lines changed

11 files changed

+403
-0
lines changed

Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM cyberbotics/webots:R2020b-rev1-ubuntu20.04
2+
3+
RUN apt-get update && \
4+
apt-get install -y git python3-yaml jq
5+
6+
COPY scripts /bin/scripts
7+
COPY controllers ${WEBOTS_HOME}/webots/resources/projects/controllers
8+
COPY entrypoint.sh /entrypoint.sh
9+
10+
ENTRYPOINT ["/entrypoint.sh"]

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Webots Animation Action
2+
3+
This GitHub action creates a Webots animation of a simulation and publishes it to GitHub pages.
4+
5+
<p align="center">
6+
<img src="./assets/cover.png">
7+
</p>
8+
9+
10+
After each commit, Webots simulation will be recorded and published to `<username>.github.io/<repository>` as an X3D animation.
11+
In your browser, you can move around and zoom while the animation is playing.
12+
13+
## Workflow
14+
15+
Here is a simple GitHub workflow snippet which utilizes the action:
16+
```yaml
17+
name: Record animation
18+
19+
on:
20+
push:
21+
branches:
22+
- master
23+
24+
jobs:
25+
record:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- name: Check out the repo
29+
uses: actions/checkout@v2
30+
- name: Record and deploy the animation
31+
uses: cyberbotics/webots-animation-action@master
32+
```
33+
> You can save the snippet to e.g.: `.github/workflows/record_animation.yml`.
34+
35+
## Configuration
36+
37+
You can create `webots.yaml` configuration file in the root of your repository to fine tune generated animations.
38+
If the file is not present, the action will automatically generate animations for all files according to the default configuration.
39+
40+
```yaml
41+
animation:
42+
worlds:
43+
- file: worlds/tutorial_6.wbt
44+
duration: 5
45+
- file: worlds/tutorial_1.wbt
46+
duration: 10
47+
```
48+
49+
The world options are:
50+
51+
| **name** | **description** |
52+
|------------|---------------------------------------------|
53+
| `file` | Path to world file (.wbt) |
54+
| `duration` | Animation duration in seconds (default 10s) |
55+
56+
## Examples
57+
58+
Check out [Webots Animation Template](https://github.com/cyberbotics/webots-animation-template/) repository.

action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
name: 'Webots Animation'
2+
description: 'Build and publish Webots animation on every commit'
3+
runs:
4+
using: 'docker'
5+
image: 'Dockerfile'

assets/cover.png

98.3 KB
Loading

assets/cover.xcf

697 KB
Binary file not shown.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import argparse
2+
from controller import Supervisor
3+
4+
5+
def main():
6+
parser = argparse.ArgumentParser()
7+
parser.add_argument('--duration', type=float, default=10, help='Duration of the animation in seconds')
8+
parser.add_argument('--output', default='../../animation/index.html', help='Path at which the animation will be saved')
9+
args = parser.parse_args()
10+
11+
robot = Supervisor()
12+
timestep = int(robot.getBasicTimeStep())
13+
robot.animationStartRecording(args.output)
14+
15+
step_i = 0
16+
n_steps = (1000 * args.duration) / robot.getBasicTimeStep()
17+
while robot.step(timestep) != -1 and step_i < n_steps:
18+
step_i += 1
19+
20+
robot.animationStopRecording()
21+
print('The animation is saved')
22+
robot.simulationQuit(0)
23+
24+
if __name__ == '__main__':
25+
main()

entrypoint.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/bash
2+
3+
mkdir /tmp/animation
4+
python3 /bin/scripts/run.py
5+
6+
NAME=$(curl https://api.github.com/users/${GITHUB_ACTOR} | jq -r .name)
7+
ID=$(curl https://api.github.com/users/${GITHUB_ACTOR} | jq -r .id)
8+
9+
git config --global user.name "${NAME}"
10+
git config --global user.email "${ID}+${GITHUB_ACTOR}@users.noreply.github.com"
11+
git reset --hard
12+
git fetch
13+
git checkout gh-pages || git checkout -b gh-pages
14+
rm -rf $(ls -aI '.git') 2> /dev/null
15+
cp -r /tmp/animation/* .
16+
git add -A
17+
git commit -m "Updated animation"
18+
git push "https://$GITHUB_ACTOR:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY"

scripts/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__

scripts/config/webots.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
animation:
2+
worlds:
3+
- file: worlds/*.wbt
4+
duration: 10

scripts/run.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Copyright 1996-2020 Cyberbotics Ltd.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import os
18+
import re
19+
import subprocess
20+
import collections
21+
from glob import glob
22+
import yaml
23+
24+
25+
def dict_merge(dct, merge_dct):
26+
""" Recursive dict merge. Inspired by :meth:``dict.update()``, instead of
27+
updating only top-level keys, dict_merge recurses down into dicts nested
28+
to an arbitrary depth, updating keys. The ``merge_dct`` is merged into
29+
``dct``.
30+
:param dct: dict onto which the merge is executed
31+
:param merge_dct: dct merged into dct
32+
:return: None
33+
"""
34+
# Reference: https://gist.github.com/angstwad/bf22d1822c38a92ec0a9
35+
for k in merge_dct.keys():
36+
if (k in dct and isinstance(dct[k], dict)
37+
and isinstance(merge_dct[k], collections.abc.Mapping)):
38+
dict_merge(dct[k], merge_dct[k])
39+
else:
40+
dct[k] = merge_dct[k]
41+
42+
43+
def load_config():
44+
default_config_dir = os.path.join(
45+
os.path.dirname(os.path.realpath(__file__)), 'config')
46+
47+
# Load user's config
48+
user_config = {}
49+
if os.path.isfile('webots.yaml'):
50+
with open('webots.yaml', 'r') as f:
51+
user_config = yaml.load(f.read(), Loader=yaml.FullLoader) or {}
52+
53+
# Load default config
54+
config = None
55+
with open(os.path.join(default_config_dir, 'webots.yaml'), 'r') as f:
56+
config = yaml.load(f.read(), Loader=yaml.FullLoader)
57+
58+
# Put user's config on top of default config
59+
dict_merge(config, user_config)
60+
return config
61+
62+
63+
def generate_animation_recorder_vrml(duration, output):
64+
return (
65+
f'Robot {{\n'
66+
f' name "supervisor"\n'
67+
f' controller "animation_recorder"\n'
68+
f' controllerArgs [\n'
69+
f' "--duration={duration}"\n'
70+
f' "--output={output}"\n'
71+
f' ]\n'
72+
f' supervisor TRUE\n'
73+
f'}}\n'
74+
)
75+
76+
77+
def get_world_name_from_path(path):
78+
return os.path.splitext(os.path.basename(path))[0]
79+
80+
81+
def generate_animation_list(animation_config):
82+
template = None
83+
template_dir = os.path.dirname(os.path.realpath(__file__))
84+
with open(os.path.join(template_dir, 'template.html'), 'r') as f:
85+
template = f.read()
86+
87+
worlds = []
88+
for world in animation_config['worlds']:
89+
for world_file in glob(world['file']):
90+
description = ''
91+
title = ''
92+
with open(world_file, 'r') as f:
93+
world_content = f.read()
94+
95+
# Parse `title`
96+
title_expr = re.compile(r'title\s\"(.*?)\"', re.MULTILINE | re.DOTALL)
97+
title_re = re.findall(title_expr, world_content)
98+
if title_re:
99+
title = title_re[0]
100+
101+
# Parse `info`
102+
info_expr = re.compile(r'info\s\[(.*?)\]', re.MULTILINE | re.DOTALL)
103+
info_re = re.findall(info_expr, world_content)
104+
if info_re:
105+
description = ' '.join([x.strip().strip('"') for x in info_re[0].split('\n') if x.strip().strip('"')])
106+
107+
worlds.append({
108+
'title': title,
109+
'description': description,
110+
'name': get_world_name_from_path(world_file)
111+
})
112+
113+
template = template.replace('{ WORLD_LIST_PLACEHOLDER }', str(worlds))
114+
115+
with open(os.path.join('/tmp/animation', 'index.html'), 'w') as f:
116+
f.write(template)
117+
118+
119+
def generate_animation(animation_config):
120+
generate_animation_list(animation_config)
121+
122+
for world in animation_config['worlds']:
123+
for world_file in glob(world['file']):
124+
world_content = None
125+
world_name = get_world_name_from_path(world_file)
126+
animation_recorder_vrml = generate_animation_recorder_vrml(
127+
duration=world['duration'],
128+
output=os.path.join(os.path.abspath('.'), '/tmp/animation', world_name + '.html')
129+
)
130+
131+
with open(world_file, 'r') as f:
132+
world_content = f.read()
133+
134+
with open(world_file, 'w') as f:
135+
f.write(world_content + animation_recorder_vrml)
136+
137+
out = subprocess.check_output(['xvfb-run', 'webots', '--stdout', '--stderr', '--batch', '--mode=fast', world_file])
138+
print(out.decode('utf-8'))
139+
140+
with open(world_file, 'w') as f:
141+
f.write(world_content)
142+
143+
144+
def main():
145+
config = load_config()
146+
generate_animation(config['animation'])
147+
148+
149+
if __name__ == "__main__":
150+
main()

0 commit comments

Comments
 (0)