!/usr/bin/env python import os import re import subprocess import tempfile import json import yaml import sys
GTN_HOME = os.path.join(os.path.dirname(os.path.realpath(__file__)), “..”)
ANSIBLE_HOST_OVERRIDE = “gat-1.be.training.galaxyproject.eu” GTN_URL = “training.galaxyproject.org/training-material” GXY_URL = “gat-1.be.training.galaxyproject.eu” GIT_GAT = ‘/home/ubuntu/galaxy/’
GTN_URL = “localhost:4002/training-material” GIT_GAT = ‘/home/hxr/arbeit/galaxy/git-gat’
data = yaml.safe_load(open(sys.argv, “r”).read()) meta = data steps = data
import os
if os.path.exists(“.step.cache.json”):
with open(".step.cache.json", "r") as handle:
steps = json.load(handle)
# First pass, we’ll record all of the audio, and nothing else. for step in steps:
# Ignore cached data
if "mediameta" in step:
continue
# Need to record
if "text" in step:
tmp = tempfile.NamedTemporaryFile(mode="w", delete=False)
tmp.write(step["text"])
tmp.close()
cmd = [
"ruby",
"bin/ari-synthesize.rb",
"--aws",
"-f",
tmp.name,
"--voice=" + meta["voice"]["id"],
"--lang=" + meta["voice"]["lang"],
]
if not meta["voice"]["neural"]:
cmd.append("--non-neural")
# Create the mp3
mp3 = subprocess.check_output(cmd).decode("utf-8").strip()
# And remove our temp file
try:
os.unlink(tmp)
except:
pass
print(mp3)
with open(mp3.replace(".mp3", ".json"), "r") as handle:
mediameta = json.load(handle)
del mediameta["value"]
step["mediameta"] = mediameta
step["mediameta"]["fn"] = mp3
if "visual" not in step["data"]:
raise Exception("Hey, missing a visual, we can't process this script.")
with open(".step.cache.json", "w") as handle:
json.dump(steps, handle)
def loadGitGatCommits(tutorial=“admin/cvmfs”):
subprocess.check_call(["git", "stash"], cwd=GIT_GAT)
subprocess.check_call(["git", "checkout", "main"], cwd=GIT_GAT)
results = subprocess.check_output(["git", "log", "--pretty=reference"], cwd=GIT_GAT).decode("utf-8")
commits = results.strip().split("\n")[::-1]
commits = [x for x in commits if tutorial in x]
commitMap = {}
for commit in commits:
m = re.match("([0-9a-f]+) \(.*: (.*), [0-9-]*\)", commit)
g = m.groups()
commitMap[g[1]] = g[0]
return commitMap
commitMap = loadGitGatCommits()
def runningGroup(steps):
c = None
for step in steps:
if c is None:
c = [step]
continue
# If the new visual is different, then
if step["data"]["visual"] != c[-1]["data"]["visual"]:
# Yield the current list
yield c
# And reset it with current step
c = [step]
else:
c.append(step)
yield c
def calculateSyncing(syncpoints, audio):
"""
# This tracks where in the video precisely (mostly) the audio should start
syncpoints = [
{"msg": "checkout", "time": 162.959351},
{"msg": "cmd", "time": 18899.63733},
{"msg": "cmd", "time": 22978.593038},
]
# Here are the lengths of the audio
audio = [
{"end": 13.608},
{"end": 3.936},
{"end": 7.488},
]
So we need to get back something that has like (the commnd took 18.89
seconds to run, but we have 13 seconds of audio, the next clip needs a 5
second delay)
{'end': 13.608} 162.959
{'end': 3.936} 5128.677
{'end': 7.488} 142.955
"""
if len(syncpoints) != len(audio):
print("????? Something odd is going on!")
since = 0
for (aud, syn) in zip(audio, syncpoints):
delay = syn["time"] - since
yield (int(delay), aud)
since = syn["time"] + (aud["mediameta"]["end"] * 1000)
def muxAudioVideo(group, videoin, videoout, syncpoints):
# We'll construct a complex ffmpeg filter here.
#
# There is incorrect quotation/line breaking in the filter_complex for clarity
# ffmpeg -i video.mp4 \
# -i test3.mp3 \
# -i test3.mp3 \
# -filter_complex \
# "[1:a]atrim=start=0,adelay=500,asetpts=PTS-STARTPTS[a1];
# [2:a]atrim=start=0,adelay=20,asetpts=PTS-STARTPTS[a2];
# [a1][a2]concat=n=2:v=0:a=1[a]" \
# -map 0:v -map "[a]" -y output.mp4
#
# We start by inputting all media (video, N audio samples)
# Then we run a filter complex.
# For each audio sample:
# we need to list it in the complex filter. We number then [1:a]... and refer to them as [a1]...
# atrim=start=0 to use the entire audio sample every time
# http://ffmpeg.org/ffmpeg-filters.html#atrim
# adelay=500 value in miliseconds, we want to offset the first
# clip relative to video start, the rest are offset
# relative to each other (which in practice is a
# negligible amount.)
# http://ffmpeg.org/ffmpeg-filters.html#adelay
# asetpts=PTS-STARTPTS[..]
# This is some magic I copied from SO. I don't super
# get what it does, something about fixing
# timestamps so there's no jitter in audio.
# http://ffmpeg.org/ffmpeg-filters.html#asetpts
# Then we list all audios with a concat filter and the number of samples.
# [a1][a2]concat=n=2:v=0:a=1[a]
# I read this as [audio1][audio2] concatentate filter with n=2samples : v(ideo)=off, a(udio)=on from [a] array of audio.
# it works?
# and lastly we map things together into our output file.
mux_cmd = ["ffmpeg", "-i", videoin]
for g in group:
mux_cmd.extend(["-i", g["mediameta"]["fn"]])
mux_cmd.append("-filter_complex")
delayResults = list(calculateSyncing(syncpoints, group))
# The first audio sample must have a correct adelay for when that action happens.
complex_filter = []
concat_filter = ""
for i2, (delay, g) in enumerate(delayResults, start=1):
filt = f"[{i2}:a]atrim=start=0,adelay={delay},asetpts=PTS-STARTPTS[a{i2}]"
print(filt)
complex_filter.append(filt)
concat_filter += f"[a{i2}]"
final_concat = f"{concat_filter}concat=n={len(group)}:v=0:a=1[a]"
complex_filter.append(final_concat)
final_complex = ";".join(complex_filter)
mux_cmd.extend([final_complex, "-map", "0:v", "-map", "[a]", videoout])
# print(" ".join(mux_cmd))
subprocess.check_call(mux_cmd)
def recordBrowser(idx):
# Record our video. It'll start with a blank screen and page loading which we'll need to crop.
if os.path.exists(f"video-{idx}.mp4"):
return
silent_video = f"video-{idx}-silent.mp4"
silent_video_cropped = f"video-{idx}-cropped.mp4"
cmd = [
"/srv/galaxy/venv/bin/node",
os.path.join(GTN_HOME, "bin", "video-browser-recorder.js"),
f"scene-{idx}.json",
silent_video,
]
print(" ".join(cmd))
resulting_script = json.loads(subprocess.check_output(cmd).decode("utf-8"))
# Get the amount of time before the first scrollTo
adelay = resulting_script[0]["time"]
# Crop the 'init' portion of the video.
cmd = [
"ffmpeg",
"-ss",
f"{adelay}ms",
"-i",
silent_video,
"-c",
"copy",
silent_video_cropped,
]
print(" ".join(cmd))
subprocess.check_call(cmd)
# Build the video with sound.
muxAudioVideo(group, silent_video_cropped, f"video-{idx}.mp4", resulting_script[1:])
def recordGtn(idx, group):
# We've got N bits of text
actions = [{"action": "goto", "target": GTN_URL + "/topics/admin/tutorials/cvmfs/tutorial.html"}]
for g in group:
actions.append(
{"action": "scrollTo", "target": g["data"]["target"], "sleep": g["mediameta"]["end"],}
)
with open(f"scene-{idx}.json", "w") as handle:
json.dump(actions, handle)
recordBrowser(idx)
def recordGxy(idx, group):
actions = [{"action": "goto", "target": GXY_URL,}]
for g in group:
action = {
"action": g["data"]["action"],
"target": g["data"]["target"],
"value": g["data"].get("value", None),
"sleep": g["mediameta"]["end"],
}
if action["action"] == "goto":
action["target"] = GXY_URL + action["target"]
actions.append(action)
with open(f"scene-{idx}.json", "w") as handle:
json.dump(actions, handle)
recordBrowser(idx)
def recordTerm(idx, group):
if os.path.exists(f"video-{idx}.mp4"):
return
actions = []
for g in group:
if "commit" in g["data"]:
g["data"]["commitId"] = commitMap[g["data"]["commit"]]
del g["code"]
# t = g.get('mediameta', {'end': -1})['end']
t = g["mediameta"]["end"]
if "commitId" in g["data"]:
actions.append({"action": "checkout", "time": t, "data": g["data"]["commitId"]})
else:
if "cmd" in g:
cmd = g["cmd"]
elif "cmd" in g["data"]:
cmd = g["data"]["cmd"]
else:
print("????? SOMETHING IS WRONG")
actions.append({"action": "cmd", "time": t, "data": cmd})
with open(f"scene-{idx}.json", "w") as handle:
json.dump(actions, handle)
# Remove any previous versions of the cast.
cast_file = f"{GTN_HOME}/scene-{idx}.cast"
if os.path.exists(cast_file):
os.unlink(cast_file)
# Do the recording
innercmd = [
"bash",
os.path.join(GTN_HOME, "bin", "video-term-recorder.sh"),
f"{GTN_HOME}/scene-{idx}.json",
f"{GTN_HOME}/scene-{idx}.log",
ANSIBLE_HOST_OVERRIDE,
GIT_GAT,
]
cmd = [
"asciinema",
"rec",
cast_file,
"-t",
f"Scene {idx}",
"-c",
" ".join(innercmd),
]
print(' '.join(cmd))
subprocess.check_call(cmd)
# Convert to MP4
subprocess.check_call(
["python", "asciicast2movie/asciicast2movie.py", f"scene-{idx}.cast", f"scene-{idx}.mp4",]
)
resulting_script = []
with open(f"scene-{idx}.log", "r") as handle:
for line in handle.readlines():
line = line.strip().split("\t")
resulting_script.append(
{"time": float(line[0]) * 1000, "msg": line[1],}
)
# Mux with audio
muxAudioVideo(group, f"scene-{idx}.mp4", f"video-{idx}.mp4", resulting_script)
# Next pass, we’ll aggregate things of the same ‘type’. This will make # recording videos easier because we can more smoothly tween between steps. # E.g. scrolling in GTN + waiting. Or recording N things in the terminal and # the audios for those. for idx, group in enumerate(runningGroup(steps)):
typ = group[0]["data"]["visual"]
# print(typ, len(group), idx)
if typ == "gtn":
recordGtn(idx, group)
elif typ == "terminal":
recordTerm(idx, group)
elif typ == "galaxy":
recordGxy(idx, group)