This document walks through the different stages you'll need to go through to create a web app that can render frames and movies.
It attempts to break features out into stages.
Let's start small and familarize ourselves with this web application.
First, let's look at the app.rb
file in the root of this project.
We're using the Sinatra framework to start this app.
When you load the initial page the server (the app.rb
file itself)
responds through this function.
First we're using the logger
to write some information to the
log file (located at log/app.log
). This can be useful in debugging.
Next we set the @flash
instance variable to welcome the users
to the application.
Lastly, the erb
function renders the page. The argument we give
to the erb
function is :index
the page we want to render. I.e.,
views/index.erb
will be the page we want to render when the
server responds.
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
To start off, let's change the flash message from
Welcome to Summer Institute!
to anything else you like.
Note that along with info
, there's also danger
to
generate a red panel and warning
to generate a yellow
panel.
The initial page that's rendered - views/index.erb
-
doesn't have a lot to it yet. It simply says Hello world!
.
Let's change this text now just to get a feel for
changing the page and seeing it render.
First we want the ability to create new rendering projects.
In this step we'll add a link (anchor (a)) in the navigation bar (nav) that points to the pages we'll setup in later steps.
You may have noticed that the navigation bar (nav) already
exists in views/layout.erb
. It already has one link that
points to the root of this project.
The structure of the links in the navigation bar (nav) are as follows: There is one single unordered list (ul) with many list item (li)s as children. Within the list item (li) is an anchor (a) that has an href that points to the page we'll be creating in later steps. Within the anchor (a) is an idiomatic text (i) tag for icons and the actual text we'll display for users.
The anchor (a)'s href needs a URL to point to.
We can use the url
Sinatra function to generate one for us.
The href should point to url('/projects/new')
.
ul (already exists)
li
a
i
"text to display"
Be sure to add the list item (li) (li) as a child of the existing unordered list (ul) (ul)
official solution - addition to views/layout.erb.
<i class="fas fa-home"></i> <%= title %>
</a>
</li>
+
+ <li class="nav-item active">
+ <a href="<%= url('/projects/new') %>" class="nav-link">
+ <i class="fas fa-camera"></i> New Project
+ </a>
+ </li>
official solution - full views/layout.erb file.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><%= title %></title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-fQybjgWLrvvRgtW6bFlB7jaZrFsaBXjsOMm/tB9LTS58ONXgqbR9W8oWht/amnpF" crossorigin="anonymous"></script>
<script src="<%= url("/app.js") %>" type="text/javascript"></script>
<link rel="icon" type="image/png" href="<%= url("/favicon.ico") %>"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" integrity="sha384-zCbKRCUGaJDkqS1kPbPd7TveP5iyJE0EjAuZQTgFLD2ylzuqKfdKlfG/eSrtxUkn" crossorigin="anonymous">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.13/css/all.css" integrity="sha384-DNOHZ68U8hZfKXOrtjWvjxusGo9WQnrNx2sqG0tfsghAvtVlRW3tvkXWZh58N9jp" crossorigin="anonymous">
</head>
<body>
<header>
<nav class="navbar navbar-expand-md navbar-dark bg-dark">
<a href="/" class="navbar-brand"><%= ENV["ONDEMAND_TITLE"] %></a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a href="<%= url("/") %>" class="nav-link">
<i class="fas fa-home"></i> <%= title %>
</a>
</li>
<li class="nav-item active">
<a href="<%= url('/projects/new') %>" class="nav-link">
<i class="fas fa-camera"></i> New Project
</a>
</li>
</ul>
</div>
</nav>
</header>
<div class="container" role="main">
<% @flash.each do |type, msg| %>
<div class="alert alert-<%= type %> alert-dismissible fade show my-3" role="alert">
<%= msg %>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<% end unless @flash.nil? %>
<%== yield %>
</div>
</body>
</html>
Now if you refresh the page, you should see a camera in the navigation bar. However, if you click it the webserver will return an error because we haven't created the server actions or pages yet.
Now let's make the server action and web page for this "New Projects" functionality.
First, let's add the server action in app.rb
. The server needs
to respond to GET requests to the /projects/new
URL path. In fact,
the error page that Sinatra responds with gives you a hint on how
to do this.
official solution - addition to app.rb.
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
+
+ get '/projects/new' do
+ erb(:new_project)
+ end
end
If you click this link at this point, you may get an error page
containing the error Errno::ENOENT
because we're trying to render
a file that does not exist yet. Simply creating the file resolves the issue.
Once you've created the views/new_project.erb
, you can start editing
it. This webpage needs to supply an form for users to fill out.
Websites use forms to pass information from the user to the server.
This form should have one text input for specifying the project name. This is the only piece of information required to create a project - the name of the project.
Tips:
- forms need a button to submit the form.
- forms need an action attribute to know where to submit the form.
- labels are not strictly required, but should always be used to label inputs.
official solution - addition to views/new_project.erb.
+<h1 class="my-3">Create a new Rendering Project</h1>
+
+<form action="<%= url("/projects/new") %>" method="post">
+
+ <div class="form-group">
+ <label for="name">Project Name</label>
+ <input type="text" name="name" class="form-control" id="name" required>
+ </div>
+
+
+ <button type="submit" class="btn btn-primary my-3">Submit</button>
+</form>
Now if you click on New Project
in the navigation bar the form should
be rendering because the file exists and the server knows that it needs to
render it for this URL.
Submitting this form however, will not work because the server does not know how to respond to POST requests at the same url.
Let's add another method to the app.rb
file so that it knows how to
respond to POST requests to /projects/new
as well as GET requests.
For simplicity in this step, let's re-render the views/new_project.erb
while providing a new @flash
message containing the parameters
that were sent. Sinatra provides the params
variable to inspect
what parameters have been sent to the web server.
official solution - addition to app.rb file.
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
+
+ get '/projects/new' do
+ erb(:new_project)
+ end
+
+ post '/projects/new' do
+ logger.info("Trying to create a project with: #{params.inspect}")
+ @flash = { info: "Trying to create a project with: #{params.inspect}" }
+
+ erb(:new_project)
+ end
end
official solution - full app.rb file.
# frozen_string_literal: true
require 'sinatra/base'
require 'logger'
# App is the main application where all your logic & routing will go
class App < Sinatra::Base
set :erb, escape_html: true
enable :sessions
attr_reader :logger
def initialize
super
@logger = Logger.new('log/app.log')
end
def title
'Summer Instititue Starter App'
end
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
get '/projects/new' do
erb(:new_project)
end
post '/projects/new' do
logger.info("Trying to create a project with: #{params.inspect}")
@flash = { info: "Trying to create a project with: #{params.inspect}" }
erb(:new_project)
end
end
official solution - full views/new_project.erb file.
<div class="d-flex justify-content-center">
<h1 class="my-2">Create a new rendering project</h1>
</div>
<form action="<%= url("/projects/new") %>" method="post">
<div class="form-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" class="form-control" required/>
</div>
<button type="submit" class="btn btn-primary my-2">Submit</button>
</form>
Now that the server knows how to respond to the different HTTP methods on
the /projects/new
route - you should be able to navigate to the new project's
page from the navigation bar, see the form, and submit the form without errors.
Now that we have the groundwork for creating projects - we need to
actually create the project in the post '/projects/new'
method
(remember this is the action that's called when the users submits the
form through a POST request).
Once users create a project, we then need a route to show the project.
This will be get '/projects/:name'
route that we'll also use to create new
projects.
Note that /projects/new
changes to /projects/:name
with :name
being a variable.
There's also a special case when :name
is new
.
Given users input the project name
- we need to:
- Sanitize the input by lowercasing it and changing any spaces to underscores.
- Create the directory on the file system.
- Redirect to the page that shows the project the user just created. Though this redirection will fail until we get to step 2b.
Tips:
- You should create project directories under the
projects
directory already a part of this application. Calling__dir__
withinapp.rb
will give you the current directory of the file (app.rb). - You can use FileUtils to create directories.
- Sinatra provides a
redirect
helper function to redirect the client to a different page.
official solution - addition to app.rb.
erb(:new_project)
end
+ # helper function for the parent directory of all projects.
+ def projects_root
+ "#{__dir__}/projects"
+ end
+
post '/projects/new' do
- logger.info("Trying to render frames with: #{params.inspect}")
- @flash = { info: "Trying to render frames with: #{params.inspect}" }
+ dir = params[:name].downcase.gsub(' ', '_')
+
+ "#{projects_root}/#{dir}".tap { |d| FileUtils.mkdir_p(d) }
- erb(:new_project)
+ session[:flash] = { info: "made new project '#{params[:name]}'" }
+ redirect(url("/projects/#{dir}"))
end
end
official solution - full app.rb file
# frozen_string_literal: true
require 'sinatra/base'
require 'logger'
# App is the main application where all your logic & routing will go
class App < Sinatra::Base
set :erb, escape_html: true
enable :sessions
attr_reader :logger
def initialize
super
@logger = Logger.new('log/app.log')
end
def title
'Summer Instititue Starter App'
end
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
get '/projects/new' do
erb(:new_project)
end
# helper function for the parent directory of all projects.
def projects_root
"#{__dir__}/projects"
end
post '/projects/new' do
dir = params[:name].downcase.gsub(' ', '_')
"#{projects_root}/#{dir}".tap { |d| FileUtils.mkdir_p(d) }
session[:flash] = { info: "made new project '#{params[:name]}'" }
redirect(url("/projects/#{dir}"))
end
end
Note that we use tap method on the string "#{projects_root}/#{dir}"
.
This one-liner is equivalent to the ruby code below, only we didn't have
to create & allocate the variable temp_variable
.
We can create the string and use it directly with tap without having to save it. We don't need to save it because we don't need to use it a second time.
temp_variable = "#{projects_root}/#{dir}"
FileUtils.mkdir_p(temp_variable)
Now when you submit the form in the get '/projects/:name'
page - you'll find
that the directory ./projects/<user input>
has been created. However,
the application doesn't know how to respond to the /projects/<user input>
route yet.
We'll create this functionality in the next step.
Now that POST requests to /projects/new
modify the system
to create a project, we need the functionality to actually show that
project.
In this step you need to:
- Modify the
get '/projects/new'
method to respond to/projects/:name
where:name
is the variable project name the user is trying to navigate to. - Create the HTML to be displayed when showing a project. This
should be
views/show_project.erb
file. It can contain anything at this point, it just needs to exist.
Tips:
- When changing the route from
/projects/new
to/projects/:name
Sinatra will extract the variable:name
from the URL and populate theparams
Hash with that key that you can access throughparams[:name]
. - See https://sinatrarb.com/intro.html for more information. You can
search this page for
:name
to see the examples. - Note that you'll have to account for the edge case when
:name
isnew
. If the:name
variable is 'new' we should renderviews/new_project.erb
instead ofviews/show_project.erb
.
official solution - addition to app.rb.
- get '/projects/new' do
- erb(:new_project)
+ get '/projects/:name' do
+ if params[:name] == 'new'
+ erb(:new_project)
+ else
+ erb(:show_project)
+ end
end
While this works fine, we need to account for the cases when the user
has input a URL to a project that doesn't exist. So we need to ensure
that when we render the show page (erb(:show_project)
) we only
render pages for valid projects.
In this step you must validate that the parameter :name
is actually
a directory.
Tips:
- Create a Pathname variable that is
projects_root
(created in a previous step) and theparams[:name]
variable. Pathname has nice helper functions likedirectory?
to check if the path is an actual directory. - Use an
if
block to check if the path is valid. If it isn't you should provide a danger flash message and redirect to the root URL ("/").
official solution - addition to app.rb.
get '/projects/:name' do
if params[:name] == 'new'
erb(:new_project)
else
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
- erb(:show_project)
+
+ if(@directory.directory? && @directory.readable?)
+ erb(:show_project)
+ else
+ session[:flash] = { danger: "#{@directory} does not exist" }
+ redirect(url('/'))
+ end
+
official solution - views/show_project.erb file.
Showing project at <%= @directory %>
full app.rb file
# frozen_string_literal: true
require 'sinatra/base'
require 'logger'
# App is the main application where all your logic & routing will go
class App < Sinatra::Base
set :erb, escape_html: true
enable :sessions
attr_reader :logger
def initialize
super
@logger = Logger.new('log/app.log')
end
def title
'Summer Instititue Starter App'
end
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
get '/projects/:name' do
if params[:name] == 'new'
erb(:new_project)
else
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
if(@directory.directory? && @directory.readable?)
erb(:show_project)
else
session[:flash] = { danger: "#{@directory} does not exist" }
redirect(url('/'))
end
end
end
# helper function for the parent directory of all projects.
def projects_root
"#{__dir__}/projects"
end
post '/projects/new' do
dir = params[:name].downcase.gsub(' ', '_')
"#{projects_root}/#{dir}".tap { |d| FileUtils.mkdir_p(d) }
session[:flash] = { info: "made new project '#{params[:name]}'" }
redirect(url("/projects/#{dir}"))
end
end
Now that we can create projects, we need the /
(index) route
to list them all out so that we can navigate to and from them.
In this step you'll need to generate an Array of all the project directories.
Tips:
- You should already have helper method
projects_root
that is the parent directory for all the projects. - You can use the Dir class to find children of that directory.
- You'll need to show only directories, filtering out files. The Pathname class is a great choice to help you do this.
Let's write a helper method called project_dirs
that will return a list
of all the children of projects_root
through the Dir class. Sorting the
list alphabetically is just a nice thing to do.
official solution - addition to app.rb file.
'Summer Instititue Starter App'
end
+ def project_dirs
+ Dir.children(projects_root).select do |path|
+ Pathname.new("#{projects_root}/#{path}").directory?
+ end.sort_by(&:to_s)
+ end
+
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
Now we can use the helper method project_dirs
to loop through each
project directory and create an unordered list (ul) with a list item (li)
for each project directory and create an anchor (a) link so users
can navigate to the /projects/:name
route for each project.
Additionally, within the anchor (a) we can use idiomatic text (i) tags for icons to make it look nice and a paragraph (p) tag to display the project name.
Here is the structure we're looking for. Note that you can also use div tags for spacing and sizing.
So, this is the structure we're going for more or less. Note that you may opt for an outer div to create the right size of icons.
ul
li
a
i
p
official solution - addition to views/index.erb file.
<%= title %>
</h1>
-Hello world!
+<h2 class="my-4">Projects</h2>
+
+<div class='row my-5'>
+ <ul class='list-group list-group-horizontal flex-wrap col-md-12'>
+ <% project_directories.each do |project_dir| %>
+ <li class='list-group-item btn btn-outline-dark m-3 border'>
+ <div>
+ <a href='<%= url("/projects/#{project_dir}") %>' class="text-center">
+ <i class='fas fa-fw fa-camera fa-5x'></i>
+ <p><%= project_dir.gsub('_', ' ').capitalize %><p/>
+ </a>
+ </div>
+ </li>
+ <% end %>
+ </ul>
+</div>
full app.rb file
# frozen_string_literal: true
require 'sinatra/base'
require 'logger'
# App is the main application where all your logic & routing will go
class App < Sinatra::Base
set :erb, escape_html: true
enable :sessions
attr_reader :logger
def initialize
super
@logger = Logger.new('log/app.log')
end
def title
'Summer Instititue Starter App'
end
def project_dirs
Dir.children(projects_root).select do |path|
Pathname.new("#{projects_root}/#{path}").directory?
end.sort_by(&:to_s)
end
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
get '/projects/:name' do
if params[:name] == 'new'
erb(:new_project)
else
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
if(@directory.directory? && @directory.readable?)
erb(:show_project)
else
session[:flash] = { danger: "#{@directory} does not exist" }
redirect(url('/'))
end
end
end
# helper function for the parent directory of all projects.
def projects_root
"#{__dir__}/projects"
end
post '/projects/new' do
dir = params[:name].downcase.gsub(' ', '_')
"#{projects_root}/#{dir}".tap { |d| FileUtils.mkdir_p(d) }
session[:flash] = { info: "made new project '#{params[:name]}'" }
redirect(url("/projects/#{dir}"))
end
end
full views/index.erb file
<h1 class="display-4 py-3 mb-3 border-bottom">
<%= title %>
</h1>
<h2 class="my-4">Projects</h2>
<div class='row my-5'>
<ol class='list-group list-group-horizontal flex-wrap col-md-12'>
<% project_dirs.each do |project_dir| %>
<li class='list-group-item btn btn-outline-dark m-3 border'>
<div>
<a href='<%= url("/projects/#{project_dir}") %>' class="text-center">
<i class='fas fa-fw fa-camera fa-5x'></i>
<p><%= project_dir.gsub('_', ' ').capitalize %><p/>
</a>
</div>
</li>
<% end %>
</ol>
</div>
Now that we can create projects and can navigate to and from them,
this is where the real work of the app starts. This application
is meant to generate frames from a blend file. So, in the show_project.erb
page we need to provide users with a form to fill out to submit a job
with various settings like how many frames they want to render from which
blend file and so on.
Note that this form should POST requests to /render/frames and this POST request will not work until we finish step 5.
We need a form that users can fill out these fields:
blend_file
select - which blend file they want to generate frames from.account
select - the account code to be used (jobs require an account for billing purposes).num_cpus
number input - how many CPUs the job will use. This should have a minimum of 4 and a maximum of 48.frame_range
text input - the range of frames the job will generate (like1-100
will generate frames 1 through 100). Noteblender
expects this field to be a specific format and that we can check for specific patterns using thepattern
attribute.walltime
number input - how long the job will run for.project_directory
hidden input - this will be a hidden field that tells the job where to output the images.
Note that we'll also need a button to submit the form and that all fields are required.
Beyond just providing the form for functionality, we should
style it too so that it looks visually pleasing to users. The
official solution provides the structure for this form as follows.
You can read this as <tag name>.<css class list>
. So a div.row
would be <div class="row">...</div>
and so on.
<sizing class>
is a column class like col-md-6
or similar. We want the form to be presented
in a visually appealing way using the [bootstrap grid] system. We want the first 2 fields to be
of size 6 (2 items taking up the whole row) and the next 3 fields to be of size 4 (3 items
taking up the whole row). project_directory
is hidden, so there's no need to style it.
form
div.col-md-12
div.row
div.form-group <sizing class>
<field>.form-control
This is the visual structure we're going for:
blend_file | account | |
num_cpus | frame_range | walltime |
Tips:
- As a first pass, you should put temporary values in the select options
for
account
andblend_file
. We'll be updating this in later phases. - Create the form with all the fields first, then add the divs and style it.
- After styling it, at a minimum you should add labels. Additionally, you could add small help text for some fields.
- Remember forms need an action and
method
attributes to know how and where to submit the form. The action will be<%= url("/render/frames") %>
(even though we haven't implemented this on the server yet) and themethod
will bepost
.
official solution - addition to views/show_project.erb file.
-Showing project at <%= @directory %>
+<form action="<%= url("/render/frames") %>" method="post" enctype="multipart/form-data">
+
+ <div class="col-md-12">
+ <div class="row">
+
+ <div class="form-group col-md-6">
+ <label for="blend_file">Blend File</label>
+ <select name="blend_file" id="blend_file" class="form-control">
+ <option value="tmp">Temp</option>
+ </select>
+ </div>
+
+ <div class="form-group col-md-6">
+ <label for="account">Account</label>
+ <select name="account" id="account" class="form-control">
+ <option value="tmp">Temp</option>
+ </select>
+ </div>
+
+ <div class="form-group col-md-4">
+ <label for="num_cpus">CPUs</label>
+ <input id="num_cpus" name="num_cpus" type="number" min="4" max="48" class="form-control" value='4' required>
+ <small class="form-text text-muted">More CPUs means less time rendering.</small>
+ </div>
+
+ <div class="form-group col-md-4">
+ <label for="frame_range">Frame Range (N-M)</label>
+ <input id="frame_range" name="frame_range" type="text" class="form-control" pattern="(\d+\.\.\d+)|(\d+(?:,\d+)*)" required>
+ <small class="form-text text-muted">Ex: "1..10" renders frames 1-10, "1,3,5" renders frames 1, 3 and 5...</small>
+ </div>
+
+ <div class="form-group col-md-4">
+ <label for="walltime">Walltime</label>
+ <input type="number" id="walltime" name="walltime" class="form-control" value="1" min="1" max="48">
+ <small class="form-text text-muted">Hours</small>
+ </div>
+
+ <div>
+ <input type="hidden" name="project_directory" id="project_directory" value="<%= @directory %>" required>
+ </div>
+
+ </div> <!-- end class="row" -->
+
+ <div class="row justify-content-md-end my-1">
+ <button type="submit" class="btn btn-primary float-right">Render Frames</button>
+ </div>
+ </div>
+
+</form>
official solution - full views/show_project.erb file.
<form action="<%= url("/render/frames") %>" method="post" enctype="multipart/form-data">
<div class="col-md-12">
<div class="row">
<div class="form-group col-md-6">
<label for="blend_file">Blend File</label>
<select name="blend_file" id="blend_file" class="form-control">
<option value="tmp">Temp</option>
</select>
</div>
<div class="form-group col-md-6">
<label for="account">Account</label>
<select name="account" id="account" class="form-control">
<option value="tmp">Temp</option>
</select>
</div>
<div class="form-group col-md-4">
<label for="num_cpus">CPUs</label>
<input id="num_cpus" name="num_cpus" type="number" min="4" max="48" class="form-control" value='4' required>
<small class="form-text text-muted">More CPUs means less time rendering.</small>
</div>
<div class="form-group col-md-4">
<label for="frame_range">Frame Range (N-M)</label>
<input id="frame_range" name="frame_range" type="text" class="form-control" pattern="(\d+\.\.\d+)|(\d+(?:,\d+)*)" required>
<small class="form-text text-muted">Ex: "1..10" renders frames 1-10, "1,3,5" renders frames 1, 3 and 5...</small>
</div>
<div class="form-group col-md-4">
<label for="walltime">Walltime</label>
<input type="number" id="walltime" name="walltime" class="form-control" value="1" min="1" max="48">
<small class="form-text text-muted">Hours</small>
</div>
<div>
<input type="hidden" name="project_directory" id="project_directory" value="<%= @directory %>" required>
</div>
</div> <!-- end class="row" -->
<div class="row justify-content-md-end my-1">
<button type="submit" class="btn btn-primary float-right">Render Frames</button>
</div>
</div>
</form>
Now that we have the form ready, let's add somethings on the backend to fill out those temporary selections and add a placeholder for the route that we POST this form to.
First, let's populate a list of accounts that you can submit jobs with.
This will populate a list that we can use in the account
select option
in the form.
We'll use the Etc and Process modules to pull the current user's
available Unix groups. Let's add this helper for accounts
that:
- takes the current processes' groups
- maps those groups (they're integers) to strings (the name of the group)
- filter that list for all groups that start with P (only groups that start with P are valid projects for the job scheduler).
official solution - addition to app.rb file.
def project_dirs
Dir.children(projects_root).select do |path|
Pathname.new("#{projects_root}/#{path}").directory?
end.sort_by(&:to_s)
end
+ def accounts
+ Process.groups.map do |group_id|
+ Etc.getgrgid(group_id).name
+ end.select do |group|
+ group.start_with?('P')
+ end
+ end
+
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
Now that the account list is populated on the backend server, we can use them in the view. Instead of having the 1 temporary select option - let's use some ERB to list out all the possible account options that one could use.
official solution - addition to views/show_project.erb file.
<div class="form-group col-md-6">
<label for="account">Account</label>
<select name="account" id="account" class="form-control">
- <option value="tmp">Temp</option>
+ <%- accounts.each do |account| -%>
+ <option value="<%= account %>"><%= account %></option>
+ <%- end -%>
</select>
</div>
Similar to the step above for accounts - let's populate the
select form field for the choice of blend file (blend_file
select).
Note that this step requires you downloading a blend file or two. At the
time of writing, version 4.2
is what's available. Blender distributes
blender demo files that are freely available. So please download
a blend file or two that is compatible with 4.2
and place them in the
blend_files
directory before starting this step.
Once you've downloaded one or two blender demo files, we first need to
get the backend server to recognize the files in the blend_files
folder.
Let's add a blend_files
helper method in the server to generate a list of
files that are available. The official solution uses the Dir module with
the glob
API to use wildcards like *
to list all files in that directory
that end with the .blend
extension.
Tips:
- Dir.glob will return the full path of the file, so you should also map that full file to the file's basename. You can use File class find the basename.
official solution - addition to app.rb file.
end
end
+ def blend_files
+ Dir.glob("#{__dir__}/blend_files/*.blend").map do |f|
+ File.basename(f)
+ end
+ end
+
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
Now that the server can list all our blend files, we need to update the view to list them out. Here we can use each to iterate through the collection and generate a select option for each blend file.
official solution - addition to views/show_project.erb file
<div class="form-group col-md-6">
<label for="blend_file">Blend File</label>
<select name="blend_file" id="blend_file" class="form-control">
- <option value="tmp">Temp</option>
+ <%- blend_files.each do |file| -%>
+ <option value="<%= file %>"><%= file %></option>
+ <%- end -%>
</select>
</div>
This step is just a couple UI enhancements to make the page
layout a little bit better in get /projects/:name
.
We'd like to add the project name to the page. Before we can add it to the HTML page, we need to define it in the server.
Recall that the directory name is the name of the project.
Also recall that we did some sanitization to the directory
changing spaces (
) to underscores (_
), so we'll want
to reverse that operation before presenting it in the UI.
Tips:
@directory
is a Pathname, which we can usebasename
on to get the directory name (and not the full path).- Refer to another location where we used
gsub
on a string to change it. - Strings also provide a
capitalize
function. project_name
should be an instance variable (i.e.,@project_name
).
official solution - addition to app.rb file.
erb(:new_project)
else
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
+ @project_name = @directory.basename.to_s.gsub('_', ' ').capitalize
if(@directory.directory? && @directory.readable?)
erb(:show_project)
Now that the server has the instance variable @project_name
,
we can display it in the web page.
The official solution uses heading elements to display the name of the project as well as the section of the page that you're rendering frames in the form.
official solution - addition to views/show_project.erb file.
+<h1 class='d-flex my-2 justify-content-center'><%= @project_name %></h1>
+
+<h2>Render Frames</h2>
+
<form action="<%= url("/render/frames") %>" method="post" enctype="multipart/form-data">
<div class="col-md-12">
official solution - full app.rb file.
# frozen_string_literal: true
require 'sinatra/base'
require 'logger'
# App is the main application where all your logic & routing will go
class App < Sinatra::Base
set :erb, escape_html: true
enable :sessions
attr_reader :logger
def initialize
super
@logger = Logger.new('log/app.log')
end
def title
'Summer Instititue Starter App'
end
def project_dirs
Dir.children(projects_root).select do |path|
Pathname.new("#{projects_root}/#{path}").directory?
end.sort_by(&:to_s)
end
def accounts
Process.groups.map do |group_id|
Etc.getgrgid(group_id).name
end.select do |group|
group.start_with?('P')
end
end
def blend_files
Dir.glob("#{__dir__}/blend_files/*.blend").map do |f|
File.basename(f)
end
end
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
get '/projects/:name' do
if params[:name] == 'new'
erb(:new_project)
else
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
@project_name = @directory.basename.to_s.gsub('_', ' ').capitalize
if(@directory.directory? && @directory.readable?)
erb(:show_project)
else
session[:flash] = { danger: "#{@directory} does not exist" }
redirect(url('/'))
end
end
end
# helper function for the parent directory of all projects.
def projects_root
"#{__dir__}/projects"
end
post '/projects/new' do
dir = params[:name].downcase.gsub(' ', '_')
"#{projects_root}/#{dir}".tap { |d| FileUtils.mkdir_p(d) }
session[:flash] = { info: "made new project '#{params[:name]}'" }
redirect(url("/projects/#{dir}"))
end
end
full views/show_project.erb file
<h1 class='d-flex my-2 justify-content-center'><%= @project_name %></h1>
<h2>Render Frames</h2>
<form action="<%= url("/render/frames") %>" method="post" enctype="multipart/form-data">
<div class="col-md-12">
<div class="row">
<div class="form-group col-md-6">
<label for="blend_file">Blend File</label>
<select name="blend_file" id="blend_file" class="form-control">
<%- blend_files.each do |file| -%>
<option value="<%= file %>"><%= file %></option>
<%- end -%>
</select>
</div>
<div class="form-group col-md-6">
<label for="account">Account</label>
<select name="account" id="account" class="form-control">
<%- accounts.each do |account| -%>
<option value="<%= account %>"><%= account %></option>
<%- end -%>
</select>
</div>
<div class="form-group col-md-4">
<label for="num_cpus">CPUs</label>
<input id="num_cpus" name="num_cpus" type="number" min="4" max="48" class="form-control" value='4' required>
<small class="form-text text-muted">More CPUs means less time rendering.</small>
</div>
<div class="form-group col-md-4">
<label for="frame_range">Frame Range (N-M)</label>
<input id="frame_range" name="frame_range" type="text" class="form-control" pattern="(\d+\.\.\d+)|(\d+(?:,\d+)*)" required>
<small class="form-text text-muted">Ex: "1..10" renders frames 1-10, "1,3,5" renders frames 1, 3 and 5...</small>
</div>
<div class="form-group col-md-4">
<label for="walltime">Walltime</label>
<input type="number" id="walltime" name="walltime" class="form-control" value="1" min="1" max="48">
<small class="form-text text-muted">Hours</small>
</div>
<div>
<input type="hidden" name="project_directory" id="project_directory" value="<%= @directory %>" required>
</div>
</div> <!-- end class="row" -->
<div class="row justify-content-md-end my-1">
<button type="submit" class="btn btn-primary float-right">Render Frames</button>
</div>
</div>
</form>
Step 4 added a form so that users can render frames from within a project view. However, the route for rendering frames does not exist yet (or doesn't do anything). In this step we'll make that route and start an HPC job that renders the frames from the chosen blend file.
If you haven't already, you can add a starter post '/render/frames
method. You can simply redirect
somewhere else in this function.
It may also be nice to provide a flash
message, perhaps containing
the params
object.
official solution - addition to app.rb file
end
end
+ post '/render/frames' do
+ session[:flash] = { info: "rendering frames with '#{params}'" }
+ redirect(url("/"))
+ end
+
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
Now if you press Render Frames
in the form of the get '/projects/:name
page you'll get redirected to the root URL with a flash message.
At this point, we need to buildout the sbatch command to run the job given all the input that the user entered in the form.
sbatch takes many command line arguments. Here's what we'll be setting
from the params
variable the user provides in the form:
account
will set the-A
flag.walltime
will set-t
flag after being formatted toHH:00:00
.num_cpus
will set-n
flag.blend_file
will populate theBLEND_FILE_PATH
environment variable.project_directory
will populate theOUTPUT_DIR
environment variable and be used to set the job's output location for the--output
flag.frame_range
will populate theFRAME_RANGE
environment variable.- We can hard code the cluster to be
pitzer
through the-M
flag. - You should also hard code the
--parseable
flag so that the command output is parseable. - We should also set the job name with the
-J
option. This job name should have theblend_file
parameter in it to distinguish the job. - The last argument to
sbatch
will be the shell script we're trying to run in the job. This shell script already exists in this project atscripts/render_frames.sh
.
We can use backtick characters (`
) to issue a command from the Ruby server.
For example `echo 'hello world'`
within your Ruby program will issue the
command echo 'hello world'
.
Tips:
- Start the
sbatch
command with as few arguments as possible. Get it to launch the job, then add parameters. - The format function to format the
params[:walltime]
into theHH:00:00
format. - You can assign the output of commands to a variable when running
commands with backticks (
`
) in Ruby. - The official solution takes the output of the
sbatch
command and displays aflash
message when the next page is loaded. You can extract this message from thesession
object in theget '/projects/:name'
method.
official solution - addition to app.rb file
else
@directory = Pathname.new("#{projects_root}/#{name}")
@project_name = @directory.basename.to_s.gsub('_', ' ').capitalize
+ @flash = session.delete(:flash)
if(@directory.directory? && @directory.readable?)
erb(:show_project)
post '/render/frames' do
- session[:flash] = { info: "rendering frames with '#{params}'" }
- redirect(url("/"))
+ logger.info("rendering frames with #{params.inspect}")
+
+ blend_file = "#{__dir__}/blend_files/#{params[:blend_file]}"
+ walltime = format('%02d:00:00', params[:walltime])
+ dir = params[:project_directory]
+
+ args = ['-J', "blender-#{params[:blend_file]}", '--parsable', '-A', params[:account]]
+ args.concat ['--export', "BLEND_FILE_PATH=#{blend_file},OUTPUT_DIR=#{dir},FRAME_RANGE=#{params[:frame_range]}"]
+ args.concat ['-n', params[:num_cpus], '-t', walltime, '-M', 'pitzer']
+ args.concat ['--output', "#{dir}/%j.out"]
+
+ output = `/bin/sbatch #{args.join(' ')} #{__dir__}/scripts/render_frames.sh 2>&1`
+ job_id = output.strip.split(';').first
+
+ session[:flash] = { info: "submitted job #{job_id}" }
+ redirect(url("/projects/#{dir.split('/').last}"))
end
get '/' do
official solution - full app.rb file.
# frozen_string_literal: true
require 'sinatra/base'
require 'logger'
# App is the main application where all your logic & routing will go
class App < Sinatra::Base
set :erb, escape_html: true
enable :sessions
attr_reader :logger
def initialize
super
@logger = Logger.new('log/app.log')
end
def title
'Summer Instititue Starter App'
end
def project_dirs
Dir.children(projects_root).select do |path|
Pathname.new("#{projects_root}/#{path}").directory?
end.sort_by(&:to_s)
end
def accounts
Process.groups.map do |group_id|
Etc.getgrgid(group_id).name
end.select do |group|
group.start_with?('P')
end
end
def blend_files
Dir.glob("#{__dir__}/blend_files/*.blend").map do |f|
File.basename(f)
end
end
post '/render/frames' do
logger.info("rendering frames with #{params.inspect}")
blend_file = "#{__dir__}/blend_files/#{params[:blend_file]}"
walltime = format('%02d:00:00', params[:walltime])
dir = params[:project_directory]
args = ['-J', "blender-#{params[:blend_file]}", '--parsable', '-A', params[:account]]
args.concat ['--export', "BLEND_FILE_PATH=#{blend_file},OUTPUT_DIR=#{dir},FRAME_RANGE=#{params[:frame_range]}"]
args.concat ['-n', params[:num_cpus], '-t', walltime, '-M', 'pitzer']
args.concat ['--output', "#{dir}/%j.out"]
output = `/bin/sbatch #{args.join(' ')} #{__dir__}/scripts/render_frames.sh 2>&1`
job_id = output.strip.split(';').first
session[:flash] = { info: "submitted job #{job_id}" }
redirect(url("/projects/#{dir.split('/').last}"))
end
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
get '/projects/:name' do
if params[:name] == 'new'
erb(:new_project)
else
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
@project_name = @directory.basename.to_s.gsub('_', ' ').capitalize
@flash = session.delete(:flash)
if(@directory.directory? && @directory.readable?)
erb(:show_project)
else
session[:flash] = { danger: "#{@directory} does not exist" }
redirect(url('/'))
end
end
end
# helper function for the parent directory of all projects.
def projects_root
"#{__dir__}/projects"
end
post '/projects/new' do
dir = params[:name].downcase.gsub(' ', '_')
"#{projects_root}/#{dir}".tap { |d| FileUtils.mkdir_p(d) }
session[:flash] = { info: "made new project '#{params[:name]}'" }
redirect(url("/projects/#{dir}"))
end
end
Now that we can submit jobs, step 6 adds an image carousel to the get '/projects/:name'
page so that users can see the output of the render job.
The official solution uses the Bootstrap carousel library to show the images on the page in a visually pleasing way.
To complete this step we need to:
- Find all the images on the backend server and assign the Array to and instance variable.
- Use the Bootstrap carousel library to display all the images.
Finding the images is as easy as using the Dir module to glob (use wildcards)
the directory where they should be. This will find all the files in the directory
that end with .png
extension and assign this Array to an instance variable
we call @images
.
official solution - addition to app.rb file
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
@project_name = @directory.basename.to_s.gsub('_', ' ').capitalize
@flash = session.delete(:flash)
+ @images = Dir.glob("#{@directory}/*.png")
if(@directory.directory? && @directory.readable?)
erb(:show_project)
The HTML to show the images is far more complicated. Looking at the Bootstrap carousel documentation we need to create some outer divs around our imgs so that Bootstrap knows where to apply the changes.
This is the basic structure of elements with CSS Classes that we'll need to get this on the page.
div[class="carousel slide" data-ride="carousel"]
div[class="carousel-inner]
<!-- loop start -->
div[class="carousel-item"] (the first image will also have the 'active' class)
img
<!-- loop end -->
This works by:
- Applying the HTML data attributes
data-ride="carousel"
to the outer mostdiv
. - Applying the [CSS Classs]es
carousel
andslide
to the outer most div. - Applying the [CSS Classs]
carousel-inner
to the inner div. - Looping through all the
@images
to create a div that has the CSS CLasscarousel-item
. This div will have a child img element that is the actual image.- The very first image will additionally have the CSS CLass
active
. Instead of each you can use each_with_index to supply the index of the Array and apply theactive
class when the index is zero. - The
@images
is an Array of full paths to the file. You can use/pun/sys/dashboard/files/fs<%= image %>
as the src attribute for the img.
- The very first image will additionally have the CSS CLass
official solution - addition to views/show_project.erb file
<h1 class='d-flex my-2 justify-content-center'><%= @project_name %></h1>
+<div class="row my-3">
+
+ <div id="blend_image_carousel" class="carousel slide" data-ride="carousel">
+ <div id="blend_image_carousel_inner" class="carousel-inner">
+
+ <%- @images.each_with_index do |image, index| -%>
+ <div id="<%= File.basename(image) %>" class="carousel-item <%= index == 0 ? 'active' : nil %>">
+ <img class="d-block w-100" src="/pun/sys/dashboard/files/fs<%= image %>">
+ </div>
+ <%- end -%>
+
+ </div> <!-- carousel inner -->
+
+ </div><!-- carousel -->
+</div>
+
+
<h2>Render Frames</h2>
With the carousel created, you should see the images in the get '/projects/:name'
routes. The bootstrap javascript should be iterating through these images.
That's all well and good, but should still enable a way for users to navigate through all the images.
First, we'll add an unordered list (ul) with list item (li)s to be our carousel indicators. Carousel indicators are the items at the bottom of the carousel that users can click on to navigate to specific images.
We'll add this unordered list (ul) as a sibling to the div with the CSS Class carousel-inner
.
So if we take the structure from step 6a and add this, it becomes:
div[class="carousel slide" data-ride="carousel"]
div[class="carousel inner"]
<!-- loop over each image begin -->
div[class="carousel-item"] (the first image will also have class 'active')
img
<!-- loop end >
ol[class="carousel-indicators"]
<!-- loop over each image number begin -->
li[data-slide-to="the image number"] (the first indicator will have the class 'active')
<!-- loop end >
This works by:
- Applying the CSS Class
carousel-indicators
to the unordered list (ul) - Each list item (li) needs:
- A
data-target
HTML data attributes. This is a CSS Selector that should just be a query for theid
ofid
of the outer most div with thedata-ride="carousel"
. A CSS Selector for anid
is just#
and theid
for example#my_div_id
whenmy_div_id
is theid
of the div you're looking for. Note you may have to add an id to the outer most div if you haven't already. - A
data-slide-to
HTML data attributes that is the image number that indicator will slide to. Note thatdata-slide-to
numbers are expected to start at 0. - The very first list item (li) will need the CSS Class
active
.
- A
Tips:
- The list item (li)s all need a number to know where to slide to.
- The
@images
instance variable is an Array so you can calllength
on that array to find the length of the Array. - You can use the Range class to create another Array that is
all the numbers 1 through
@images.length
. - Or you can use each_with_index on
@images
and just disregard the image variable, using only the index variable.
- The
- Note that the Bootstrap carousel library expects the
data-slide-to
HTML data attributes to start at 0. So if you have 2 images, thedata-slide-to
attributes would be0
and1
not1
and2
.
official solution - update to views/show_project.erb file.
<div id="blend_image_carousel" class="carousel slide" data-ride="carousel">
+ <ol id="blend_image_carousel_indicators" class="carousel-indicators">
+ <% ([email protected]).each do |index| %>
+ <li data-target="#blend_image_carousel" data-slide-to="<%= index-1 %>" class="<%= index == 1 ? 'active' : nil %>" ></li>
+ <% end %>
+ </ol>
+
<div id="blend_image_carousel_inner" class="carousel-inner">
Now you should have indicators at the bottom of the images. There should be one for each image. They should be clickable and correctly
Now that we have carousel indicators, we also want to add buttons to navigate to the previous and next images.
We'll use anchor (a)s that are siblings to the div with the
CSS Class carousel-inner
.
These anchor (a)s will have two children, both of them spans. The first span will be the actual clickable icon. The second is for Accessibility of screen readers to indicate what this button does (because there's no visual text for what the button does).
Tips:
- This is the structure with CSS Classes. Note that this example is
for the previous button. The next button would use
carousel-control-next
andcarousel-control-next-icon
CSS Classes.
a[class="carousel-control-prev" role="button" href="#blend_image_carousel" data-slide="prev"]
span[class="carousel-control-prev-icon" aria-hidden="true"]
span[class="sr-only"]
Previous
official solution - addition to views/show_project.erb file.
</div> <!-- carousel inner -->
+ <a class="carousel-control-prev" href="#blend_image_carousel" role="button" data-slide="prev">
+ <span class="carousel-control-prev-icon" aria-hidden="true"></span>
+ <span class="sr-only">Previous</span>
+ </a>
+
+ <a class="carousel-control-next" href="#blend_image_carousel" role="button" data-slide="next">
+ <span class="carousel-control-next-icon" aria-hidden="true"></span>
+ <span class="sr-only">Next</span>
+ </a>
+
</div><!-- carousel -->
official solution - full app.rb file.
# frozen_string_literal: true
require 'sinatra/base'
require 'logger'
# App is the main application where all your logic & routing will go
class App < Sinatra::Base
set :erb, escape_html: true
enable :sessions
attr_reader :logger
def initialize
super
@logger = Logger.new('log/app.log')
end
def title
'Summer Instititue Starter App'
end
def project_dirs
Dir.children(projects_root).select do |path|
Pathname.new("#{projects_root}/#{path}").directory?
end.sort_by(&:to_s)
end
def accounts
Process.groups.map do |group_id|
Etc.getgrgid(group_id).name
end.select do |group|
group.start_with?('P')
end
end
def blend_files
Dir.glob("#{__dir__}/blend_files/*.blend").map do |f|
File.basename(f)
end
end
post '/render/frames' do
logger.info("rendering frames with #{params.inspect}")
blend_file = "#{__dir__}/blend_files/#{params[:blend_file]}"
walltime = format('%02d:00:00', params[:walltime])
dir = params[:project_directory]
args = ['-J', "blender-#{params[:blend_file]}", '--parsable', '-A', params[:account]]
args.concat ['--export', "BLEND_FILE_PATH=#{blend_file},OUTPUT_DIR=#{dir},FRAME_RANGE=#{params[:frame_range]}"]
args.concat ['-n', params[:num_cpus], '-t', walltime, '-M', 'pitzer']
args.concat ['--output', "#{dir}/%j.out"]
output = `/bin/sbatch #{args.join(' ')} #{__dir__}/scripts/render_frames.sh 2>&1`
job_id = output.strip.split(';').first
session[:flash] = { info: "submitted job #{job_id}" }
redirect(url("/projects/#{dir.split('/').last}"))
end
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
get '/projects/:name' do
if params[:name] == 'new'
erb(:new_project)
else
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
@project_name = @directory.basename.to_s.gsub('_', ' ').capitalize
@flash = session.delete(:flash)
@images = Dir.glob("#{@directory}/*.png")
if(@directory.directory? && @directory.readable?)
erb(:show_project)
else
session[:flash] = { danger: "#{@directory} does not exist" }
redirect(url('/'))
end
end
end
# helper function for the parent directory of all projects.
def projects_root
"#{__dir__}/projects"
end
post '/projects/new' do
dir = params[:name].downcase.gsub(' ', '_')
"#{projects_root}/#{dir}".tap { |d| FileUtils.mkdir_p(d) }
session[:flash] = { info: "made new project '#{params[:name]}'" }
redirect(url("/projects/#{dir}"))
end
end
official solution - full views/show_project.erb file.
<h1 class='d-flex my-2 justify-content-center'><%= @project_name %></h1>
<div class="row my-3">
<div id="blend_image_carousel" class="carousel slide" data-ride="carousel">
<ol id="blend_image_carousel_indicators" class="carousel-indicators">
<% (1..@images.length).each do |index| %>
<li data-target="#blend_image_carousel" data-slide-to="<%= index-1 %>" class="<%= index == 1 ? 'active' : nil %>" ></li>
<% end %>
</ol>
<div id="blend_image_carousel_inner" class="carousel-inner">
<%- @images.each_with_index do |image, index| -%>
<div id="<%= File.basename(image) %>" class="carousel-item <%= index == 0 ? 'active' : nil %>">
<img class="d-block w-100" src="/pun/sys/dashboard/files/fs<%= image %>">
</div>
<%- end -%>
</div> <!-- carousel inner -->
<a class="carousel-control-prev" href="#blend_image_carousel" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next" href="#blend_image_carousel" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a>
</div><!-- carousel -->
</div>
<h2>Render Frames</h2>
<form action="<%= url("/render/frames") %>" method="post" enctype="multipart/form-data">
<div class="col-md-12">
<div class="row">
<div class="form-group col-md-6">
<label for="blend_file">Blend File</label>
<select name="blend_file" id="blend_file" class="form-control">
<%- blend_files.each do |file| -%>
<option value="<%= file %>"><%= file %></option>
<%- end -%>
</select>
</div>
<div class="form-group col-md-6">
<label for="account">Account</label>
<select name="account" id="account" class="form-control">
<%- accounts.each do |account| -%>
<option value="<%= account %>"><%= account %></option>
<%- end -%>
</select>
</div>
<div class="form-group col-md-4">
<label for="num_cpus">CPUs</label>
<input id="num_cpus" name="num_cpus" type="number" min="4" max="48" class="form-control" value='4' required>
<small class="form-text text-muted">More CPUs means less time rendering.</small>
</div>
<div class="form-group col-md-4">
<label for="frame_range">Frame Range (N-M)</label>
<input id="frame_range" name="frame_range" type="text" class="form-control" pattern="(\d+\.\.\d+)|(\d+(?:,\d+)*)" required>
<small class="form-text text-muted">Ex: "1..10" renders frames 1-10, "1,3,5" renders frames 1, 3 and 5...</small>
</div>
<div class="form-group col-md-4">
<label for="walltime">Walltime</label>
<input type="number" id="walltime" name="walltime" class="form-control" value="1" min="1" max="48">
<small class="form-text text-muted">Hours</small>
</div>
<div>
<input type="hidden" name="project_directory" id="project_directory" value="<%= @directory %>" required>
</div>
</div> <!-- end class="row" -->
<div class="row justify-content-md-end my-1">
<button type="submit" class="btn btn-primary float-right">Render Frames</button>
</div>
</div>
</form>
Having the carousel is great, but as it is in step 6, users have to manually refresh the page to see any updates. This is a poor user experience, so step 7 adds some javascript to query the filesystem for new images. When there are new images from the rendering job, the javascript will fetch it and add it to the page automatically without users having to refresh the page.
We're using the jquery javascript framework for convenience.
The javascript file public/app.js
is already loaded on every page.
We're going to add to this file in this step.
jQuery
is a function that will run when the window page load event
is fired. I.e., when the page is loaded.
Let's change this slightly by:
- Adding a new function called
updateCarousel
that takes no arguments. - This new function
updateCarousel
should do something simple like logging some simple message throughconsole.log
. - The block provided to the
jQuery
function should call this new functionupdateCarousel
.
Note that you may have to hard refresh the page (ctrl + shift + r) to download the new app.js file.
official solution - update to public/app.js file.
jQuery(() => {
- console.log('hello world');
+ updateCarousel();
});
+
+function updateCarousel() {
+ console.log('hello world from the updateCarousel function.');
+}
Now that we have a helper function to update the carousel, let's get started with that work!
First we need a way to pass the project's directory to the javascript running on the client's browser. We can do this through HTML data attributes.
Let's add a hidden div with on data attribute for the directory.
The CSS Class d-none
sets the display attribute to none to make
it invisible.
Tips:
- A div with no text will not be visible on the page. However
adding the CSS Class
d-none
will ensure that it's not on the page. - This div will need an id so that we can easily query for the element.
- HTML data attributes can be arbitrarily named. That is, there's noting
preventing you from adding
data-asdnwenbtadsnsdf='foo'
to an element. However, it would be hard to query for that. So you should likely just usedata-directory
to make it easy.
official solution - update to views/show_project.erb file.
</div>
</div>
</form>
+
+<div class='d-none' id="project_config" data-directory="<%= @directory %>">
+</div>
Now in public/app.js
we can query for this element and extract
the directory so that we can then later list the files in that
directory.
First we use plain javascript APIs like getElementById to get
the HTML element we're looking for. Note that app.js
is being
loaded on every page. So if configElement
is null
, we should
just exit because we're not on a project's page.
Once we have the element we're looking for, we can use it's
dataset to find the directory
. We'll need this parameter
because that is the file system location we'll be inspecting
for new png images.
Tips:
- Use getElementById to find the element we're looking for. (We're looking for the element we just created in this step).
- Remember to check if this element is
null
which it will be on the index page. - HTML data attributes are present in javascript objects through the dataset property.
official solution - addition to public/app.js file.
function updateCarousel() {
- console.log('hello world');
+ const configElement = document.getElementById('project_config');
+ if(configElement == null) {
+ return;
+ }
+
+ const directory = configElement.dataset.directory;
+
+ console.log(`will be querying ${directory} for new images.`);
}
Now that we know what directory we want to query to find the new images - we can start making those queries.
We'll be using the javascript's fetch API to make HTTP calls to Open OnDemand's files app.
Since we already know the directory we need to query,
we can put that directly in the url
variable.
Next we'll need a set of options, importantly,
the Accept header. This tells the server what
we're willing to accept in the response. We specify that
we're only willing to accept application/json
in the
server's response.
We can then call fetch and simply turn the data into json format. We'll then just log it to the console in this step.
Tips:
- The URL parameter is
/pun/sys/dashboard/files/fs/
+ the directory you're searching. - Be sure to use the
'Accept': 'application/json'
HTTP Header to tell the server you want ajson
response. - Although the response is actually
json
, the initial response from fetch will be a text string. Use thejson()
function on the response to turn into actualjson
data. - fetch will return a Promise object. You can chain together many instances of then after a Promise resolves. The data returned in one then will be the input to the next then.
official solution - update to public/app.js file.
console.log(`will be querying ${directory} for new images.`);
+
+ const url = `/pun/sys/dashboard/files/fs/${directory}`;
+ const options = {
+ headers: {
+ 'Accept': 'application/json'
+ }
+ }
+
+ fetch(url, options)
+ .then(response => response.json())
+ .then(data => console.log(data));
}
The files response we're getting from the server isn't exactly what we need. So, we're going to need to do some translations and filtering before we can update the DOM (Document Object Model).
We need to:
- Extract the file property from the json response.
- Extract the name property of the file from the file data.
- Filter the list of names for only names that end with png.
Tips:
- Use your browsers console to inspect the json object. (it should be printing to the console log).
- Use the map (js) function to map data from one format to another.
- Use the filter function to filter data.
official solution - addition to public/app.js file.
fetch(url, options)
.then(response => response.json())
- .then(data => console.log(data));
+ .then(data => data['files'])
+ .then(files => files.map(file => file['name']))
+ .then(files => files.filter(file => file.endsWith('png')))
+ .then(files => {
+ for(const file of files) {
+ console.log(file);
+ }
+ });
}
Now that we've extracted all the file names that currently exist on the filesystem, we can almost begin to modify the DOM (Document Object Model). Let's setup the scaffoling to do just that.
We need to:
- While looping through all the images - determine if the page already has that image.
- At the end of the loop call updateCarousel again to continue searching for new files.
In the loop of all files, we can generate the HTML id and use
getElementById to query for the iamge. If we find the image
is already on the page (the query returned something that is not
null
) we can just continue the loop.
If we don't find the image already on the DOM (Document Object Model) we'll just log that we will be adding it.
Note that in step 6a you may not have provided a unique id to each image. The div that wraps images should have a unique id based off of the filename itself.
As the last step, we can use setTimeout to call the updateCarousel
function all over again in 30,000 milliseconds (30 seconds) thereby
continuing our search for new images.
Tips:
- Use setTimeout to call
updateCarousel
again at some point in the future. - The div wrapping the img needs a unique id. If you didn't apply unique ids in step 6a, you need to do so now.
- You can use getElementById to find the div that holds the img.
If this returns
null
the image does not yet exist on the page.
official solution - addition to public/app.js file.
.then(files => files.filter(file => file.endsWith('png')))
.then(files => {
for(const file of files) {
- console.log(file);
+
+ const image = document.getElementById(file);
+
+ // image is already on the DOM so just return.
+ if(image != null) {
+ console.log(`skipping ${file} because it's already on the DOM.`);
+ continue;
+ }
+
+ console.log(`adding ${file} to the DOM.`);
+
}
+
+ setTimeout(updateCarousel, 30000);
});
}
Now that we have the javascript built out to query the filesystem for new files, we need to edit the DOM (Document Object Model) to add the new file.
Fist, we'll make the new HTML div. The div
we're attempting to make is given below. This should look
familar from the views/show_project.erb
.
<div id="render_0001_png" class="carousel-item">
<img class="d-block w-100" src="/path/to/image/render_0001.png">
</div>
To do this with javascript we'll use the createElement API.
Let's create the outer div first. We'll use the classList API
to add the carousel-item
class to it.
To add the img element as the inner child HTML to the parent
div newImage
we can use the innerHTML API and provide a string.
Tips:
- Use the createElement API do create new elements.
- Use the classList property to add CSS Classes to the element.
- Use innerHTML to define the inner HTML of the element.
- Note this can be a string and you can use template literals
like
`constant and ${variable}`
to create strings.
- Note this can be a string and you can use template literals
like
official solution - addition to public/app.js file
console.log(`adding ${imageId} to the DOM.`);
+ newImage = document.createElement('div');
+ newImage.id = file;
+ newImage.classList.add('carousel-item');
+ newImage.innerHTML = `<img class="d-block w-100" src="/pun/sys/dashboard/files/fs/${directory}/${file}" >`;
+
}
setTimeout(updateCarousel, 30000);
Now we have the javascript creating a new div and img which is great. However, the Bootstrap carousel has list item (li) indicators at the bottom for navigation.
We can't add the image without the list item (li) indicator, so let's do that now.
The HTML we're trying to build is similar to this (though the numbers
in data-slide-to
will be variable).
<li data-target="#blend_image_carousel" data-slide-to="1"></li>
Again, we'll use the createElement API, but this time passing
li
as the argument.
We can use the setAttribute API to add HTML data attributes to the list item (li).
official solution - addition to public/app.js file.
newImage.innerHTML = `<img class="d-block w-100" src="/pun/sys/dashboard/files/fs/${q}/${file}" >`;
-
+ const newIndicator = document.createElement('li');
+ newIndicator.setAttribute('data-target', '#blend_image_carousel');
}
setTimeout(updateCarousel, 30000);
We also need to set the data-slide-to
HTML data attributes as
well. To do this however, we need to find the current size of the
[ol] so that we can add 1 to that value to get the correct
data-slide-to
value.
Luckily the [ol] in question has the id blend_image_carousel_indicators
.
So we can use the handy getElementById to find it. When we call
children on this element, it'll return an Array of child elements.
We can then call length
on that array to find the number of children
in the [ol].
official solution - addition to public/app.js file
newImage.innerHTML = `<img class="d-block w-100" src="/pun/sys/dashboard/files/fs/${q}/${file}" >`;
+
+ const indicatorList = document.getElementById('blend_image_carousel_indicators');
+ const totalImages = indicatorList.children.length;
const newIndicator = document.createElement('li');
newIndicator.setAttribute('data-target', '#blend_image_carousel');
+ newIndicator.setAttribute('data-slide-to', totalImages);
}
setTimeout(updateCarousel, 30000);
Now that we have the elements, there's one edge case we need to take care of - what happens when there are no images on the page?
Well, if there are no images on the page yet,
we need to add the active
CSS Class to the indicator
and image. We can check the totalImages
to see if
it's 0 or not. If it is, we'll apply the CSS Class.
Tips:
- Use the classList property on the element to add the
active
class.
official solution - addition to public/app.js file.
newIndicator.setAttribute('data-target', '#blend_image_carousel');
newIndicator.setAttribute('data-slide-to', totalImages);
+
+ if(totalImages == 0) {
+ newIndicator.classList.add('active');
+ newImage.classList.add('active');
+ }
}
setTimeout(updateCarousel, 30000);
With that edge case out of the way - we can now actually add the newly created elements to the DOM (Document Object Model).
We can do this through the append API on elements that are already a part of the DOM (Document Object Model).
Note that we want to append the new image divs to the
blend_image_carousel_inner
element, so we have to query
for it.
Tips:
- append will append the new element as a child of the existing element.
newIndicator.classList.add('active');
newImage.classList.add('active');
}
+ const carousel = document.getElementById('blend_image_carousel_inner');
+
+ carousel.append(newImage);
+ indicatorList.append(newIndicator);
}
There is an edge case we have to account for and it's this: What happens when the page loads without any images and the javascript is adding the very first image?
The answer is: nothing. This is becuase we need to apply the active
CSS Class to the image if it's the very first image.
Tips:
- We have the variable
totalImages
which is the number of total images. If it is0
- then this is the first image and we need to apply the CSS Classactive
. active
needs to be applied to both the div that holds the image and the list item (li) that is the indicator.- You can use the classList property to add the
active
class to these elements.
official solution - addition to the public/app.js file.
const carousel = document.getElementById('blend_image_carousel_inner');
+ if(totalImages == 0){
+ newImage.classList.add('active');
+ newIndicator.classList.add('active');
+ }
+
carousel.append(newImage);
indicatorList.append(newIndicator);
}
official solution - full public/app.js file.
jQuery(() => {
updateCarousel();
});
function updateCarousel() {
const configElement = document.getElementById('project_config');
if(configElement == null) {
return;
}
const directory = configElement.dataset.directory;
const url = `/pun/sys/dashboard/files/fs/${directory}`;
const options = {
headers: {
'Accept': 'application/json'
}
}
fetch(url, options)
.then(response => response.json())
.then(data => data['files'])
.then(files => files.map(file => file['name']))
.then(files => files.filter(file => file.endsWith('png')))
.then(files => {
for(const file of files) {
const image = document.getElementById(file);
// image is already on the DOM so just return.
if(image != null) {
console.log(`skipping ${file} because it's already on the DOM.`);
continue;
}
console.log(`adding ${file} to the DOM.`);
newImage = document.createElement('div');
newImage.id = file;
newImage.classList.add('carousel-item');
newImage.innerHTML = `<img class="d-block w-100" src="/pun/sys/dashboard/files/fs/${directory}/${file}" >`;
const indicatorList = document.getElementById('blend_image_carousel_indicators');
const totalImages = indicatorList.children.length;
const newIndicator = document.createElement('li');
newIndicator.setAttribute('data-target', '#blend_image_carousel');
newIndicator.setAttribute('data-slide-to', totalImages);
const carousel = document.getElementById('blend_image_carousel_inner');
if(totalImages == 0){
newImage.classList.add('active');
newIndicator.classList.add('active');
}
carousel.append(newImage);
indicatorList.append(newIndicator);
}
setTimeout(updateCarousel, 30000);
});
}
Now we have facilities to render frames which is great! However, frames (images) are not the result we want. We want to bundle these frames into a movie.
Step 8 creates another form for users to fill out to that will submit
another job that will create an mp4
video file out of all the images
you've created.
We're going to need another form for users to fill out to submit another job.
This form will need:
account
is a select widget which is the same account from step 4a.frames_per_second
is a number input to define the frames per second the video is rendered with.num_cpus
will be a number input to define how many CPUs the job will use just like in step 4a.walltime
is an number input to define the job's running time just like in step 4a.project_directory
is a hidden input to define the proejct's directory just like in step 4a.
There are 4 visible form items for the user to fill out.
For simplicity, we'll use col-md-3
sizes for all form
fields so that they all fit on the same row.
This form will submit to a route that doesn't exist yet.
It should submit to url("/render/video")
which we will
implement in the next step.
Tips:
- Many of these are already defined in the other form you created in step 4a. Refer to them for guidance.
- Remember that forms need an action and a button.
official solution - addition to views/show_project.erb
<div id="project_config" class="d-none" data-directory="<%= @directory %>">
</div>
+
+<h2 class="my-2">Render Video</h2>
+
+<form action="<%= url("/render/video") %>" method="post">
+ <div class="col-md-12">
+ <div class="row">
+
+ <div class="form-group col-md-3">
+ <label for="account">Account</label>
+ <select name="account" id="account" class="form-control">
+ <%- accounts.each do |account| -%>
+ <option value="<%= account %>"><%= account %></option>
+ <%- end -%>
+ </select>
+ </div>
+
+ <div class="form-group col-md-3">
+ <label for="frames_per_second">Frames Per Second</label>
+ <input class="form-control" type="number" max="60" name="frames_per_second">
+ </div>
+
+ <div class="form-group col-md-3">
+ <label for="num_cpus">CPUs</label>
+ <input id="num_cpus" name="num_cpus" type="number" min="1" max="48" class="form-control" value='4' required>
+ <small class="form-text text-muted">More CPUs means less time rendering.</small>
+ </div>
+
+ <div class="form-group col-md-3">
+ <label for="walltime">Walltime</label>
+ <input type="number" id="walltime" name="walltime" class="form-control" value="1" min="1" max="48">
+ <small class="form-text text-muted">Hours</small>
+ </div>
+
+ <div>
+ <input type="hidden" name="project_directory" id="project_directory" value="<%= @directory %>" required>
+ </div>
+
+ </div> <!-- row -->
+
+ <div class="row justify-content-md-end my-1">
+ <button type="submit" class="btn btn-primary float-right">Render Frames</button>
+ </div>
+ </div>
+</form>
Just as before, we made the form before we made the route
that can handle it. Similar to the post '/render/frames'
route, we need to make a post '/render/videos'
route.
This method will be very similar to the method in
post '/render/frames'
where we build an sbatch
command to submit a job.
The script we'll be submitting is scripts/render_video.sh
.
The sbatch command will use the input from
Once again, sbatch takes many command line arguments. Here's what
we'll be setting from the params
variable the user provides in
the form:
account
will set the-A
flag.walltime
will set-t
flag after being formatted toHH:00:00
.num_cpus
will set-n
flag.frames_per_second
will populate theFRAMES_PER_SECOND
environment variable.project_directory
will populate theOUTPUT_DIR
environment variable and be used to set the job's output location for the--output
flag.
Tips:
- Start small and build on what you have. You can start just by
defining the method, then flashing params. I.e.,
@flash = params.inspect
. - Look at
post '/render/frames'
or step 4 for additional information on how to issue a command in Ruby.
official solution - addition to app.rb file.
session[:flash] = { info: "made new project '#{params[:name]}'" }
redirect(url("/projects/#{dir}"))
end
+
+ post '/render/video' do
+ logger.info("Trying to render video with: #{params.inspect}")
+
+ output_dir = params[:project_directory]
+ frames_per_second = params[:frames_per_second]
+ walltime = format('%02d:00:00', params[:walltime])
+
+ args = ['-J', 'blender-video', '--parsable', '-A', params[:account]]
+ args.concat ['--export', "FRAMES_PER_SEC=#{frames_per_second},OUTPUT_DIR=#{output_dir}"]
+ args.concat ['-n', params[:num_cpus], '-t', walltime, '-M', 'pitzer']
+ args.concat ['--output', "#{output_dir}/video-render-%j.out"]
+ output = `/bin/sbatch #{args.join(' ')} #{__dir__}/scripts/render_video.sh 2>&1`
+
+ job_id = output.strip.split(';').first
+
+ session[:flash] = { info: "Submitted job #{job_id}"}
+ redirect(url("/projects/#{output_dir.split('/').last}"))
+ end
end
official solution - full app.rb file
# frozen_string_literal: true
require 'sinatra/base'
require 'logger'
# App is the main application where all your logic & routing will go
class App < Sinatra::Base
set :erb, escape_html: true
enable :sessions
attr_reader :logger
def initialize
super
@logger = Logger.new('log/app.log')
end
def title
'Summer Instititue Starter App'
end
def project_dirs
Dir.children(projects_root).select do |path|
Pathname.new("#{projects_root}/#{path}").directory?
end.sort_by(&:to_s)
end
def accounts
Process.groups.map do |group_id|
Etc.getgrgid(group_id).name
end.select do |group|
group.start_with?('P')
end
end
def blend_files
Dir.glob("#{__dir__}/blend_files/*.blend").map do |f|
File.basename(f)
end
end
post '/render/frames' do
logger.info("rendering frames with #{params.inspect}")
blend_file = "#{__dir__}/blend_files/#{params[:blend_file]}"
walltime = format('%02d:00:00', params[:walltime])
dir = params[:project_directory]
args = ['-J', "blender-#{params[:blend_file]}", '--parsable', '-A', params[:account]]
args.concat ['--export', "BLEND_FILE_PATH=#{blend_file},OUTPUT_DIR=#{dir},FRAME_RANGE=#{params[:frame_range]}"]
args.concat ['-n', params[:num_cpus], '-t', walltime, '-M', 'pitzer']
args.concat ['--output', "#{dir}/%j.out"]
output = `/bin/sbatch #{args.join(' ')} #{__dir__}/scripts/render_frames.sh 2>&1`
job_id = output.strip.split(';').first
session[:flash] = { info: "submitted job #{job_id}" }
redirect(url("/projects/#{dir.split('/').last}"))
end
get '/' do
logger.info('requsting the index')
@flash = session.delete(:flash) || { info: 'Welcome to Summer Institute!' }
erb(:index)
end
get '/projects/:name' do
if params[:name] == 'new'
erb(:new_project)
else
@directory = Pathname.new("#{projects_root}/#{params[:name]}")
@project_name = @directory.basename.to_s.gsub('_', ' ').capitalize
@flash = session.delete(:flash)
@images = Dir.glob("#{@directory}/*.png")
if(@directory.directory? && @directory.readable?)
erb(:show_project)
else
session[:flash] = { danger: "#{@directory} does not exist" }
redirect(url('/'))
end
end
end
# helper function for the parent directory of all projects.
def projects_root
"#{__dir__}/projects"
end
post '/projects/new' do
dir = params[:name].downcase.gsub(' ', '_')
"#{projects_root}/#{dir}".tap { |d| FileUtils.mkdir_p(d) }
session[:flash] = { info: "made new project '#{params[:name]}'" }
redirect(url("/projects/#{dir}"))
end
post '/render/video' do
logger.info("Trying to render video with: #{params.inspect}")
output_dir = params[:project_directory]
frames_per_second = params[:frames_per_second]
walltime = format('%02d:00:00', params[:walltime])
args = ['-J', 'blender-video', '--parsable', '-A', params[:account]]
args.concat ['--export', "FRAMES_PER_SEC=#{frames_per_second},OUTPUT_DIR=#{output_dir}"]
args.concat ['-n', params[:num_cpus], '-t', walltime, '-M', 'pitzer']
args.concat ['--output', "#{output_dir}/video-render-%j.out"]
output = `/bin/sbatch #{args.join(' ')} #{__dir__}/scripts/render_video.sh 2>&1`
job_id = output.strip.split(';').first
session[:flash] = { info: "Submitted job #{job_id}"}
redirect(url("/projects/#{output_dir.split('/').last}"))
end
end
official solution - full views/show_project.erb file.
<h1 class='d-flex my-2 justify-content-center'><%= @project_name %></h1>
<div class="row my-3">
<div id="blend_image_carousel" class="carousel slide" data-ride="carousel">
<ol id="blend_image_carousel_indicators" class="carousel-indicators">
<% (1..@images.length).each do |index| %>
<li data-target="#blend_image_carousel" data-slide-to="<%= index-1 %>" class="<%= index == 1 ? 'active' : nil %>" ></li>
<% end %>
</ol>
<div id="blend_image_carousel_inner" class="carousel-inner">
<%- @images.each_with_index do |image, index| -%>
<div id="<%= File.basename(image) %>" class="carousel-item <%= index == 0 ? 'active' : nil %>">
<img class="d-block w-100" src="/pun/sys/dashboard/files/fs<%= image %>">
</div>
<%- end -%>
</div> <!-- carousel inner -->
<a class="carousel-control-prev" href="#blend_image_carousel" role="button" data-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="sr-only">Previous</span>
</a>
<a class="carousel-control-next" href="#blend_image_carousel" role="button" data-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</a>
</div><!-- carousel -->
</div>
<h2>Render Frames</h2>
<form action="<%= url("/render/frames") %>" method="post" enctype="multipart/form-data">
<div class="col-md-12">
<div class="row">
<div class="form-group col-md-6">
<label for="blend_file">Blend File</label>
<select name="blend_file" id="blend_file" class="form-control">
<%- blend_files.each do |file| -%>
<option value="<%= file %>"><%= file %></option>
<%- end -%>
</select>
</div>
<div class="form-group col-md-6">
<label for="account">Account</label>
<select name="account" id="account" class="form-control">
<%- accounts.each do |account| -%>
<option value="<%= account %>"><%= account %></option>
<%- end -%>
</select>
</div>
<div class="form-group col-md-4">
<label for="num_cpus">CPUs</label>
<input id="num_cpus" name="num_cpus" type="number" min="4" max="48" class="form-control" value='4' required>
<small class="form-text text-muted">More CPUs means less time rendering.</small>
</div>
<div class="form-group col-md-4">
<label for="frame_range">Frame Range (N-M)</label>
<input id="frame_range" name="frame_range" type="text" class="form-control" pattern="(\d+\.\.\d+)|(\d+(?:,\d+)*)" required>
<small class="form-text text-muted">Ex: "1..10" renders frames 1-10, "1,3,5" renders frames 1, 3 and 5...</small>
</div>
<div class="form-group col-md-4">
<label for="walltime">Walltime</label>
<input type="number" id="walltime" name="walltime" class="form-control" value="1" min="1" max="48">
<small class="form-text text-muted">Hours</small>
</div>
<div>
<input type="hidden" name="project_directory" id="project_directory" value="<%= @directory %>" required>
</div>
</div> <!-- end class="row" -->
<div class="row justify-content-md-end my-1">
<button type="submit" class="btn btn-primary float-right">Render Frames</button>
</div>
</div>
</form>
<div id="project_config" class="d-none" data-directory="<%= @directory %>">
</div>
<h2 class="my-2">Render Video</h2>
<form action="<%= url("/render/video") %>" method="post">
<div class="col-md-12">
<div class="row">
<div class="form-group col-md-3">
<label for="account">Account</label>
<select name="account" id="account" class="form-control">
<%- accounts.each do |account| -%>
<option value="<%= account %>"><%= account %></option>
<%- end -%>
</select>
</div>
<div class="form-group col-md-3">
<label for="frames_per_second">Frames Per Second</label>
<input class="form-control" type="number" max="60" name="frames_per_second">
</div>
<div class="form-group col-md-3">
<label for="num_cpus">CPUs</label>
<input id="num_cpus" name="num_cpus" type="number" min="1" max="48" class="form-control" value='4' required>
<small class="form-text text-muted">More CPUs means less time rendering.</small>
</div>
<div class="form-group col-md-3">
<label for="walltime">Walltime</label>
<input type="number" id="walltime" name="walltime" class="form-control" value="1" min="1" max="48">
<small class="form-text text-muted">Hours</small>
</div>
<div>
<input type="hidden" name="project_directory" id="project_directory" value="<%= @directory %>" required>
</div>
</div> <!-- row -->
<div class="row justify-content-md-end my-1">
<button type="submit" class="btn btn-primary float-right">Render Frames</button>
</div>
</div>
</form>
Congratulations! At this point you're done. But, like anything else, there's always more to do. Here are a couple examples of things you can add to this application.
- Add the ability to request multiple nodes instead of just 1. This way rendering
frames can go much quicker becuase you'll be able to use more cores than any one
machine has.
- Hint: Break the problem up! Instead of rendering
1..100
frames on a single machine, get the program to render1..50
on one machine and51..100
on the other. The environment variableSLURM_ARRAY_TASK_ID
will be a different number for each machine you've requested.
- Hint: Break the problem up! Instead of rendering
- Add the ability to change the project icon. Right now, every project icon in the index page
is a camera (it's an icon -
i
- tag withfas fa-fw fa-camera fa-5x
CSS classes). Make this configurable so that when you create a new project, you get to choose the icon.- Hint: google fontawesome for the entire list of icons you can use.
- Once you've completed stop 8, you can additionally display the video on the webpage.
- Add the ability to track jobs. Once the job is created, the status of the job
is not indicated on these pages. This makes the user navigate away from this page
to see the job's status. This extra credit work would add the ability to track the
jobs you submit through the forms.
- Hint: Save the job id to a file that you can read back later.
- Hint: Use the squeue command to query for the state of the job.