
Move the 'Single Image Render' job type to the 'third party job types' section on the website. It needs more testing & finessing before it can be bundled with Flamenco.
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
const JOB_TYPE = {
|
|
label: "Single Image Render",
|
|
description: "Distributed rendering of a single image.",
|
|
settings: [
|
|
// Settings for artists to determine:
|
|
{
|
|
key: "tile_size_x",
|
|
type: "int32",
|
|
default: 64,
|
|
description: "Tile size in pixels for the X axis"
|
|
},
|
|
{
|
|
key: "tile_size_y",
|
|
type: "int32",
|
|
default: 64,
|
|
description: "Tile size in pixels for the Y axis"
|
|
},
|
|
{
|
|
key: "frame", type: "int32", required: true,
|
|
eval: "C.scene.frame_current",
|
|
description: "Frame to render. Examples: '47', '1'"
|
|
},
|
|
|
|
// render_output_root + add_path_components determine the value of render_output_path.
|
|
{
|
|
key: "render_output_root",
|
|
type: "string",
|
|
subtype: "dir_path",
|
|
required: true,
|
|
visible: "submission",
|
|
description: "Base directory of where render output is stored. Will have some job-specific parts appended to it"
|
|
},
|
|
{
|
|
key: "add_path_components",
|
|
type: "int32",
|
|
required: true,
|
|
default: 0,
|
|
propargs: {min: 0, max: 32},
|
|
visible: "submission",
|
|
description: "Number of path components of the current blend file to use in the render output path"
|
|
},
|
|
{
|
|
key: "render_output_path", type: "string", subtype: "file_path", editable: false,
|
|
eval: "str(Path(abspath(settings.render_output_root), last_n_dir_parts(settings.add_path_components), jobname, '{timestamp}', 'tiles'))",
|
|
description: "Final file path of where render output will be saved"
|
|
},
|
|
|
|
// Automatically evaluated settings:
|
|
{
|
|
key: "blendfile",
|
|
type: "string",
|
|
required: true,
|
|
description: "Path of the Blend file to render",
|
|
visible: "web"
|
|
},
|
|
{
|
|
key: "format",
|
|
type: "string",
|
|
required: true,
|
|
eval: "C.scene.render.image_settings.file_format",
|
|
visible: "web"
|
|
},
|
|
{
|
|
key: "image_file_extension",
|
|
type: "string",
|
|
required: true,
|
|
eval: "C.scene.render.file_extension",
|
|
visible: "hidden",
|
|
description: "File extension used when rendering images"
|
|
},
|
|
{
|
|
key: "resolution_x",
|
|
type: "int32",
|
|
required: true,
|
|
eval: "C.scene.render.resolution_x",
|
|
visible: "hidden",
|
|
description: "Resolution X"
|
|
},
|
|
{
|
|
key: "resolution_y",
|
|
type: "int32",
|
|
required: true,
|
|
eval: "C.scene.render.resolution_y",
|
|
visible: "hidden",
|
|
description: "Resolution Y"
|
|
},
|
|
{
|
|
key: "resolution_scale",
|
|
type: "int32",
|
|
required: true,
|
|
eval: "C.scene.render.resolution_percentage",
|
|
visible: "hidden",
|
|
description: "Resolution scale"
|
|
}
|
|
]
|
|
};
|
|
|
|
function compileJob(job) {
|
|
print("Single Image Render job submitted");
|
|
print("job: ", job);
|
|
|
|
const settings = job.settings;
|
|
const renderOutput = renderOutputPath(job);
|
|
|
|
if (settings.resolution_scale !== 100) {
|
|
throw "Flamenco currently does not support rendering with a resolution scale other than 100%";
|
|
}
|
|
|
|
// Make sure that when the job is investigated later, it shows the
|
|
// actually-used render output:
|
|
settings.render_output_path = renderOutput;
|
|
|
|
const renderDir = path.dirname(renderOutput);
|
|
const renderTasks = authorRenderTasks(settings, renderDir, renderOutput);
|
|
const mergeTask = authorMergeTask(settings, renderDir);
|
|
|
|
for (const rt of renderTasks) {
|
|
job.addTask(rt);
|
|
}
|
|
if (mergeTask) {
|
|
// If there is a merge task, all other tasks have to be done first.
|
|
for (const rt of renderTasks) {
|
|
mergeTask.addDependency(rt);
|
|
}
|
|
job.addTask(mergeTask);
|
|
}
|
|
}
|
|
|
|
// Do field replacement on the render output path.
|
|
function renderOutputPath(job) {
|
|
let path = job.settings.render_output_path;
|
|
if (!path) {
|
|
throw "no render_output_path setting!";
|
|
}
|
|
return path.replace(/{([^}]+)}/g, (match, group0) => {
|
|
switch (group0) {
|
|
case "timestamp":
|
|
return formatTimestampLocal(job.created);
|
|
default:
|
|
return match;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Calculate the borders for the tiles
|
|
// Does not take into account the overscan
|
|
function calcBorders(tileSizeX, tileSizeY, width, height) {
|
|
let borders = [];
|
|
for (let y = 0; y < height; y += tileSizeY) {
|
|
for (let x = 0; x < width; x += tileSizeX) {
|
|
borders.push([x, y, Math.min(x + tileSizeX, width), Math.min(y + tileSizeY, height)]);
|
|
}
|
|
}
|
|
print("borders: ", borders);
|
|
return borders;
|
|
}
|
|
|
|
function authorRenderTasks(settings, renderDir, renderOutput) {
|
|
print("authorRenderTasks(", renderDir, renderOutput, ")");
|
|
let renderTasks = [];
|
|
let borders = calcBorders(settings.tile_size_x, settings.tile_size_y, settings.resolution_x, settings.resolution_y);
|
|
for (let border of borders) {
|
|
const task = author.Task(`render-${border[0]}-${border[1]}`, "blender");
|
|
// Overscan is calculated in this manner to avoid rendering outside the image resolution
|
|
let pythonExpr = `import bpy
|
|
|
|
scene = bpy.context.scene
|
|
render = scene.render
|
|
render.image_settings.file_format = 'OPEN_EXR_MULTILAYER'
|
|
render.use_compositing = False
|
|
render.use_stamp = False
|
|
overscan = 16
|
|
|
|
render.border_min_x = max(${border[0]} - overscan, 0) / ${settings.resolution_x}
|
|
render.border_min_y = max(${border[1]} - overscan, 0) / ${settings.resolution_y}
|
|
render.border_max_x = min(${border[2]} + overscan, ${settings.resolution_x}) / ${settings.resolution_x}
|
|
render.border_max_y = min(${border[3]} + overscan, ${settings.resolution_x}) / ${settings.resolution_y}
|
|
render.use_border = True
|
|
render.use_crop_to_border = True
|
|
bpy.ops.render.render(write_still=True)`
|
|
const command = author.Command("blender-render", {
|
|
exe: "{blender}",
|
|
exeArgs: "{blenderArgs}",
|
|
argsBefore: [],
|
|
blendfile: settings.blendfile,
|
|
args: [
|
|
"--render-output", path.join(renderDir, path.basename(renderOutput), border[0] + "-" + border[1] + "-" + border[2] + "-" + border[3]),
|
|
"--render-format", settings.format,
|
|
"--python-expr", pythonExpr
|
|
]
|
|
});
|
|
task.addCommand(command);
|
|
renderTasks.push(task);
|
|
}
|
|
return renderTasks;
|
|
}
|
|
|
|
function authorMergeTask(settings, renderDir, renderOutput) {
|
|
print("authorMergeTask(", renderDir, ")");
|
|
const task = author.Task("merge", "blender");
|
|
// Burning metadata into the image is done by the compositor for the entire merged image
|
|
// The overall logic of the merge is as follows:
|
|
// 1. Find out the Render Layers node and to which socket it is connected
|
|
// 2. Load image files from the tiles directory.
|
|
// Their correct position is determined by their filename.
|
|
// 3. Create a node tree that scales, translates and adds the tiles together.
|
|
// A simple version of the node tree is linked here:
|
|
// https://devtalk.blender.org/uploads/default/original/3X/f/0/f047f221c70955b32e4b455e53453c5df716079e.jpeg
|
|
// 4. The final image is then fed into the socket the Render Layers node was connected to.
|
|
// This allows the compositing to work as if the image was rendered in one go.
|
|
let pythonExpr = `import bpy
|
|
|
|
render = bpy.context.scene.render
|
|
render.resolution_x = ${settings.resolution_x}
|
|
render.resolution_y = ${settings.resolution_y}
|
|
bpy.context.scene.use_nodes = True
|
|
render.use_compositing = True
|
|
render.use_stamp = True
|
|
node_tree = bpy.context.scene.node_tree
|
|
overscan = 16
|
|
|
|
render_layers_node = None
|
|
for node in node_tree.nodes:
|
|
if node.type == 'R_LAYERS':
|
|
feed_in_input = node.outputs[0]
|
|
render_layers_node = node
|
|
break
|
|
for link in node_tree.links:
|
|
if feed_in_input is not None and link.from_socket == feed_in_input:
|
|
feed_in_output = link.to_socket
|
|
break
|
|
|
|
from pathlib import Path
|
|
|
|
root = Path("${path.join(renderDir, path.basename(renderOutput))}/tiles")
|
|
image_files = [f for f in root.iterdir() if f.is_file()]
|
|
|
|
separate_nodes = []
|
|
first_crop_node = None
|
|
translate_nodes = []
|
|
min_width = min([int(f.stem.split('-')[2]) - int(f.stem.split('-')[0]) for f in image_files])
|
|
min_height = min([int(f.stem.split('-')[3]) - int(f.stem.split('-')[1]) for f in image_files])
|
|
for i, image_file in enumerate(image_files):
|
|
image_node = node_tree.nodes.new('CompositorNodeImage')
|
|
image_node.image = bpy.data.images.load(str(root / image_file.name))
|
|
|
|
crop_node = node_tree.nodes.new('CompositorNodeCrop')
|
|
crop_node.use_crop_size = True
|
|
left, top, right, bottom = image_file.stem.split('-')
|
|
actual_width = int(right) - int(left)
|
|
actual_height = int(bottom) - int(top)
|
|
if left == '0':
|
|
crop_node.min_x = 0
|
|
crop_node.max_x = actual_width
|
|
else:
|
|
crop_node.min_x = overscan
|
|
crop_node.max_x = actual_width + overscan
|
|
if top == '0':
|
|
crop_node.max_y = 0
|
|
crop_node.min_y = actual_height
|
|
else:
|
|
crop_node.max_y = overscan
|
|
crop_node.min_y = actual_height + overscan
|
|
if i == 0:
|
|
first_crop_node = crop_node
|
|
|
|
translate_node = node_tree.nodes.new('CompositorNodeTranslate')
|
|
# translate_node.use_relative = True
|
|
translate_node.inputs[1].default_value = float(left) + (actual_width - ${settings.resolution_x}) / 2
|
|
translate_node.inputs[2].default_value = float(top) + (actual_height - ${settings.resolution_y}) / 2
|
|
translate_nodes.append(translate_node)
|
|
|
|
separate_node = node_tree.nodes.new('CompositorNodeSeparateColor')
|
|
separate_nodes.append(separate_node)
|
|
|
|
node_tree.links.new(image_node.outputs[0], crop_node.inputs[0])
|
|
node_tree.links.new(crop_node.outputs[0], translate_node.inputs[0])
|
|
node_tree.links.new(translate_node.outputs[0], separate_node.inputs[0])
|
|
|
|
scale_node = node_tree.nodes.new('CompositorNodeScale')
|
|
scale_node.space = 'RELATIVE'
|
|
scale_node.inputs[1].default_value = ${settings.resolution_x} / min_width
|
|
scale_node.inputs[2].default_value = ${settings.resolution_y} / min_height
|
|
node_tree.links.new(first_crop_node.outputs[0], scale_node.inputs[0])
|
|
mix_node = node_tree.nodes.new('CompositorNodeMixRGB')
|
|
mix_node.blend_type = 'MIX'
|
|
mix_node.inputs[0].default_value = 0.0
|
|
mix_node.inputs[1].default_value = (0, 0, 0, 1)
|
|
node_tree.links.new(scale_node.outputs[0], mix_node.inputs[2])
|
|
|
|
mix_adds = [node_tree.nodes.new('CompositorNodeMixRGB') for _ in range(len(separate_nodes))]
|
|
math_adds = [node_tree.nodes.new('CompositorNodeMath') for _ in range(len(separate_nodes))]
|
|
for i, mix_add in enumerate(mix_adds):
|
|
mix_add.blend_type = 'ADD'
|
|
if i == 0:
|
|
node_tree.links.new(mix_node.outputs[0], mix_add.inputs[1])
|
|
else:
|
|
node_tree.links.new(mix_adds[i - 1].outputs[0], mix_add.inputs[1])
|
|
node_tree.links.new(translate_nodes[i].outputs[0], mix_add.inputs[2])
|
|
|
|
for i, math_add in enumerate(math_adds):
|
|
math_add.operation = 'ADD'
|
|
if i == 0:
|
|
node_tree.links.new(mix_node.outputs[0], math_add.inputs[0])
|
|
else:
|
|
node_tree.links.new(math_adds[i - 1].outputs[0], math_add.inputs[0])
|
|
node_tree.links.new(separate_nodes[i - 1].outputs[3], math_add.inputs[1])
|
|
|
|
set_alpha_node = node_tree.nodes.new('CompositorNodeSetAlpha')
|
|
set_alpha_node.mode = 'REPLACE_ALPHA'
|
|
node_tree.links.new(mix_adds[-1].outputs[0], set_alpha_node.inputs[0])
|
|
node_tree.links.new(math_adds[-1].outputs[0], set_alpha_node.inputs[1])
|
|
if feed_in_input is not None:
|
|
node_tree.links.new(set_alpha_node.outputs[0], feed_in_output)
|
|
else:
|
|
raise Exception('No Render Layers Node found. Currently only supported with a Render Layers Node in the Compositor.')
|
|
|
|
node_tree.nodes.remove(render_layers_node)
|
|
bpy.ops.render.render(write_still=True)`
|
|
|
|
const command = author.Command("blender-render", {
|
|
exe: "{blender}",
|
|
exeArgs: "{blenderArgs}",
|
|
argsBefore: [],
|
|
blendfile: settings.blendfile,
|
|
args: [
|
|
"--render-output", path.join(renderDir, path.basename(renderOutput), "merged"),
|
|
"--render-format", settings.format,
|
|
"--python-expr", pythonExpr
|
|
]
|
|
});
|
|
task.addCommand(command);
|
|
return task;
|
|
}
|