From 187ca0d2b50d73a1911ddce392fce57d3113a33b Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sun, 21 Jul 2024 15:23:54 -0400 Subject: [PATCH 01/18] Manager: basic structure of the custom job type --- .../scripts/single_image_render.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 internal/manager/job_compilers/scripts/single_image_render.js diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js new file mode 100644 index 00000000..be9ceada --- /dev/null +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -0,0 +1,23 @@ +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" }, + + // 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}', '######'))", + description: "Final file path of where render output will be saved"}, + ] +}; + +function compileJob(job) { + print("Single Image Render job submitted"); + print("job: ", job); +} -- 2.30.2 From ea6111e3aba54673eb46931c692ee0bfc1284209 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sun, 21 Jul 2024 23:35:01 -0400 Subject: [PATCH 02/18] Manager: reformat and add render script --- .../scripts/single_image_render.js | 155 ++++++++++++++++-- 1 file changed, 138 insertions(+), 17 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index be9ceada..b8076075 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -1,23 +1,144 @@ 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" }, + 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" + }, - // 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}', '######'))", - description: "Final file path of where render output will be saved"}, - ] + // 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}', '######'))", + 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" + }, + ] }; function compileJob(job) { - print("Single Image Render job submitted"); - print("job: ", job); + print("Single Image Render job submitted"); + print("job: ", job); + + const settings = job.settings; + const renderOutput = renderOutputPath(job); + + // 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); +} + +// 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; + } + }); +} + +function calcBorders(tileSizeX, tileSizeY, width, height) { + 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 = []; + render = C.scene.render; + let borders = calcBorders(settings.tile_size_x, settings.tile_size_y, render.resolution_x, render.resolution_y); + for (border of borders) { + const task = author.Task(`render-${border[0]}-${border[1]}`, "blender"); + let pythonExpr = `import bpy + +render = bpy.context.scene.render +render.image_settings.file_format = 'OPEN_EXR_MULTILAYER' +render.use_compositing = False + +render.border_min_x = ${border[0]} // settings.resolution_x +render.border_min_y = ${border[1]} // settings.resolution_y +render.border_max_x = ${border[2]} // settings.resolution_x +render.border_max_y = ${border[3]} // settings.resolution_y +render.use_border = True` + const command = author.Command("blender-render", { + exe: "{blender}", + exeArgs: "{blenderArgs}", + argsBefore: [], + blendfile: settings.blendfile, + args: [ + "--render-output", path.join(renderDir, path.basename(renderOutput)), + "--render-format", settings.format, + "--python-expr", pythonExpr + ] + }); + task.addCommand(command); + renderTasks.push(task); + } + return renderTasks; } -- 2.30.2 From edcbe9f2fae9c5b99514157eded85288fddb16e8 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 25 Jul 2024 00:44:44 -0400 Subject: [PATCH 03/18] Manager: add `frame` field --- .../manager/job_compilers/scripts/single_image_render.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index b8076075..a1e4cd50 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -15,6 +15,11 @@ const JOB_TYPE = { default: 64, description: "Tile size in pixels for the Y axis" }, + { + key: "frame", type: "int32", required: true, + eval: "f'{C.scene.frame_current}'", + description: "Frame to render. Examples: '47', '1'" + }, // render_output_root + add_path_components determine the value of render_output_path. { -- 2.30.2 From ec0f9b47e2d49f73da3f950be62e8c69ab90b141 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 25 Jul 2024 23:28:15 -0400 Subject: [PATCH 04/18] Manager: able to do border rendering --- .../scripts/single_image_render.js | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index a1e4cd50..593cf398 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -17,7 +17,7 @@ const JOB_TYPE = { }, { key: "frame", type: "int32", required: true, - eval: "f'{C.scene.frame_current}'", + eval: "C.scene.frame_current", description: "Frame to render. Examples: '47', '1'" }, @@ -68,6 +68,22 @@ const JOB_TYPE = { 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" + }, ] }; @@ -84,6 +100,10 @@ function compileJob(job) { const renderDir = path.dirname(renderOutput); const renderTasks = authorRenderTasks(settings, renderDir, renderOutput); + + for (const rt of renderTasks) { + job.addTask(rt); + } } // Do field replacement on the render output path. @@ -103,7 +123,7 @@ function renderOutputPath(job) { } function calcBorders(tileSizeX, tileSizeY, width, height) { - borders = []; + 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)]); @@ -116,9 +136,8 @@ function calcBorders(tileSizeX, tileSizeY, width, height) { function authorRenderTasks(settings, renderDir, renderOutput) { print("authorRenderTasks(", renderDir, renderOutput, ")"); let renderTasks = []; - render = C.scene.render; - let borders = calcBorders(settings.tile_size_x, settings.tile_size_y, render.resolution_x, render.resolution_y); - for (border of borders) { + 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"); let pythonExpr = `import bpy @@ -126,18 +145,19 @@ render = bpy.context.scene.render render.image_settings.file_format = 'OPEN_EXR_MULTILAYER' render.use_compositing = False -render.border_min_x = ${border[0]} // settings.resolution_x -render.border_min_y = ${border[1]} // settings.resolution_y -render.border_max_x = ${border[2]} // settings.resolution_x -render.border_max_y = ${border[3]} // settings.resolution_y -render.use_border = True` +render.border_min_x = ${border[0]} / ${settings.resolution_x} +render.border_min_y = ${border[1]} / ${settings.resolution_y} +render.border_max_x = ${border[2]} / ${settings.resolution_x} +render.border_max_y = ${border[3]} / ${settings.resolution_y} +render.use_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)), + "--render-output", path.join(renderDir, path.basename(renderOutput), border[0] + "-" + border[1] + "-" + border[2] + "-" + border[3]), "--render-format", settings.format, "--python-expr", pythonExpr ] -- 2.30.2 From a33bf019014f96b1ff390b819feafb7e2ba15147 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 25 Jul 2024 23:41:58 -0400 Subject: [PATCH 05/18] Manager: add overscan --- .../manager/job_compilers/scripts/single_image_render.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 593cf398..0bbf31d5 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -144,11 +144,12 @@ function authorRenderTasks(settings, renderDir, renderOutput) { render = bpy.context.scene.render render.image_settings.file_format = 'OPEN_EXR_MULTILAYER' render.use_compositing = False +overscan = 16 -render.border_min_x = ${border[0]} / ${settings.resolution_x} -render.border_min_y = ${border[1]} / ${settings.resolution_y} -render.border_max_x = ${border[2]} / ${settings.resolution_x} -render.border_max_y = ${border[3]} / ${settings.resolution_y} +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 bpy.ops.render.render(write_still=True)` const command = author.Command("blender-render", { -- 2.30.2 From 3db9292e3b779d86c92e9fffcee0319178377d15 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sun, 28 Jul 2024 16:54:16 -0400 Subject: [PATCH 06/18] Manager: add merge task --- .../scripts/single_image_render.js | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 0bbf31d5..0f584160 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -100,10 +100,18 @@ function compileJob(job) { 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. @@ -151,6 +159,7 @@ 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}", @@ -168,3 +177,66 @@ bpy.ops.render.render(write_still=True)` } return renderTasks; } + +function authorMergeTask(settings, renderDir, renderOutput) { + print("authorMergeTask(", renderDir, ")"); + const task = author.Task("merge", "blender"); + let pythonExpr = `import bpy + +render = bpy.context.scene.render +bpy.context.scene.use_nodes = True +render.use_compositing = True +node_tree = bpy.context.scene.node_tree +feed_in_input = None +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 link.from_socket == feed_in_input: + feed_in_output = link.to_socket + +image_nodes = [] +crop_nodes = [] +from os import listdir +from os.path import isfile, join +root = "${path.join(renderDir, path.basename(renderOutput))}/######" +image_files = [f for f in listdir(root) if isfile(join(root, f))] +for image_file in image_files: + image_node = node_tree.nodes.new('CompositorNodeImage') + image_node.image = bpy.data.images.load(root + '/' + image_file) + image_nodes.append(image_node) + crop_node = node_tree.nodes.new('CompositorNodeCrop') + left, top, right, bottom = image_file.split('-') + crop_node.max_x = ${settings.tile_size_x} + crop_node.min_y = ${settings.tile_size_y} + node_tree.links.new(image_node.outputs[0], crop_node.inputs[0]) + crop_nodes.append(crop_node) +alpha_overs = [node_tree.nodes.new('CompositorNodeAlphaOver') for _ in range(len(image_nodes) - 1)] +for i in range(len(image_nodes) - 1): + if i == 0: + node_tree.links.new(crop_nodes[i].outputs[0], alpha_overs[i].inputs[1]) + else: + node_tree.links.new(alpha_overs[i - 1].outputs[0], alpha_overs[i].inputs[1]) + node_tree.links.new(crop_nodes[i + 1].outputs[0], alpha_overs[i].inputs[2]) +node_tree.links.new(alpha_overs[-1].outputs[0], feed_in_output) +node_tree.nodes.remove(render_layers_node) +render.image_settings.file_format = 'PNG' +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; +} -- 2.30.2 From cbbd48140749daf36b52e87cfa3e89b2ce38ef16 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sun, 28 Jul 2024 23:49:27 -0400 Subject: [PATCH 07/18] Manager: add translate node --- .../scripts/single_image_render.js | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 0f584160..0f5816fc 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -187,6 +187,9 @@ render = bpy.context.scene.render bpy.context.scene.use_nodes = True render.use_compositing = True node_tree = bpy.context.scene.node_tree +overscan = 16 + +# TODO: check if the render layers node actually exists feed_in_input = None render_layers_node = None for node in node_tree.nodes: @@ -198,29 +201,45 @@ for link in node_tree.links: if link.from_socket == feed_in_input: feed_in_output = link.to_socket -image_nodes = [] -crop_nodes = [] from os import listdir from os.path import isfile, join root = "${path.join(renderDir, path.basename(renderOutput))}/######" image_files = [f for f in listdir(root) if isfile(join(root, f))] + +image_nodes = [] +translate_nodes = [] for image_file in image_files: image_node = node_tree.nodes.new('CompositorNodeImage') image_node.image = bpy.data.images.load(root + '/' + image_file) - image_nodes.append(image_node) + crop_node = node_tree.nodes.new('CompositorNodeCrop') left, top, right, bottom = image_file.split('-') - crop_node.max_x = ${settings.tile_size_x} - crop_node.min_y = ${settings.tile_size_y} + if left == '0': + crop_node.min_x = 0 + crop_node.max_x = ${settings.tile_size_x} + else: + crop_node.min_x = overscan + crop_node.max_x = ${settings.tile_size_x} + overscan + if top == '0': + crop_node.max_y = 0 + crop_node.min_y = ${settings.tile_size_y} + else: + crop_node.max_y = overscan + crop_node.min_y = ${settings.tile_size_y} + overscan + + translate_node = node_tree.nodes.new('CompositorNodeTranslate') + translate_node.inputs[1].default_value = (int(left), int(top)) node_tree.links.new(image_node.outputs[0], crop_node.inputs[0]) - crop_nodes.append(crop_node) + node_tree.links.new(crop_node.outputs[0], translate_node.inputs[0]) + translate_nodes.append(translate_node) + alpha_overs = [node_tree.nodes.new('CompositorNodeAlphaOver') for _ in range(len(image_nodes) - 1)] for i in range(len(image_nodes) - 1): if i == 0: - node_tree.links.new(crop_nodes[i].outputs[0], alpha_overs[i].inputs[1]) + node_tree.links.new(translate_nodes[i].outputs[0], alpha_overs[i].inputs[1]) else: node_tree.links.new(alpha_overs[i - 1].outputs[0], alpha_overs[i].inputs[1]) - node_tree.links.new(crop_nodes[i + 1].outputs[0], alpha_overs[i].inputs[2]) + node_tree.links.new(translate_nodes[i + 1].outputs[0], alpha_overs[i].inputs[2]) node_tree.links.new(alpha_overs[-1].outputs[0], feed_in_output) node_tree.nodes.remove(render_layers_node) render.image_settings.file_format = 'PNG' -- 2.30.2 From 6fca87e42fbc8ed937769c36f4dcea123f8d542b Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sun, 4 Aug 2024 22:13:51 -0400 Subject: [PATCH 08/18] Manager: first working demo with some hacks --- .../scripts/single_image_render.js | 114 ++++++++++++------ 1 file changed, 76 insertions(+), 38 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 0f5816fc..2c9343da 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -193,56 +193,94 @@ overscan = 16 feed_in_input = None 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 + if node.type == 'R_LAYERS': + feed_in_input = node.outputs[0] + render_layers_node = node + break for link in node_tree.links: - if link.from_socket == feed_in_input: - feed_in_output = link.to_socket + if link.from_socket == feed_in_input: + feed_in_output = link.to_socket from os import listdir from os.path import isfile, join + root = "${path.join(renderDir, path.basename(renderOutput))}/######" image_files = [f for f in listdir(root) if isfile(join(root, f))] -image_nodes = [] +separate_nodes = [] +first_crop_node = None translate_nodes = [] -for image_file in image_files: - image_node = node_tree.nodes.new('CompositorNodeImage') - image_node.image = bpy.data.images.load(root + '/' + image_file) +for i, image_file in enumerate(image_files): + image_node = node_tree.nodes.new('CompositorNodeImage') + image_node.image = bpy.data.images.load(root + '/' + image_file) - crop_node = node_tree.nodes.new('CompositorNodeCrop') - left, top, right, bottom = image_file.split('-') - if left == '0': - crop_node.min_x = 0 - crop_node.max_x = ${settings.tile_size_x} - else: - crop_node.min_x = overscan - crop_node.max_x = ${settings.tile_size_x} + overscan - if top == '0': - crop_node.max_y = 0 - crop_node.min_y = ${settings.tile_size_y} - else: - crop_node.max_y = overscan - crop_node.min_y = ${settings.tile_size_y} + overscan + crop_node = node_tree.nodes.new('CompositorNodeCrop') + crop_node.use_crop_size = True + left, top, right, bottom = image_file.split('-') + if left == '0': + crop_node.min_x = 0 + crop_node.max_x = ${settings.tile_size_x} + else: + crop_node.min_x = overscan + crop_node.max_x = ${settings.tile_size_x} + overscan + if top == '0': + crop_node.max_y = 0 + crop_node.min_y = ${settings.tile_size_y} + else: + crop_node.max_y = overscan + crop_node.min_y = ${settings.tile_size_y} + overscan + if i == 0: + first_crop_node = crop_node - translate_node = node_tree.nodes.new('CompositorNodeTranslate') - translate_node.inputs[1].default_value = (int(left), int(top)) - node_tree.links.new(image_node.outputs[0], crop_node.inputs[0]) - node_tree.links.new(crop_node.outputs[0], translate_node.inputs[0]) - translate_nodes.append(translate_node) + translate_node = node_tree.nodes.new('CompositorNodeTranslate') + # translate_node.use_relative = True + translate_node.inputs[1].default_value = float(left) + (${settings.tile_size_x} - ${settings.resolution_x}) / 2 + translate_node.inputs[2].default_value = float(top) + (${settings.tile_size_y} - ${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} / ${settings.tile_size_x} +scale_node.inputs[2].default_value = ${settings.resolution_y} / ${settings.tile_size_y} +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]) +node_tree.links.new(set_alpha_node.outputs[0], feed_in_output) -alpha_overs = [node_tree.nodes.new('CompositorNodeAlphaOver') for _ in range(len(image_nodes) - 1)] -for i in range(len(image_nodes) - 1): - if i == 0: - node_tree.links.new(translate_nodes[i].outputs[0], alpha_overs[i].inputs[1]) - else: - node_tree.links.new(alpha_overs[i - 1].outputs[0], alpha_overs[i].inputs[1]) - node_tree.links.new(translate_nodes[i + 1].outputs[0], alpha_overs[i].inputs[2]) -node_tree.links.new(alpha_overs[-1].outputs[0], feed_in_output) node_tree.nodes.remove(render_layers_node) -render.image_settings.file_format = 'PNG' bpy.ops.render.render(write_still=True)` const command = author.Command("blender-render", { -- 2.30.2 From 8f63daf110fa1dcf30225c2f31981015f7880a08 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sat, 10 Aug 2024 15:14:30 -0400 Subject: [PATCH 09/18] Manager: check for cases without Render Layers Node --- .../job_compilers/scripts/single_image_render.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 2c9343da..c51dd82f 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -189,8 +189,6 @@ render.use_compositing = True node_tree = bpy.context.scene.node_tree overscan = 16 -# TODO: check if the render layers node actually exists -feed_in_input = None render_layers_node = None for node in node_tree.nodes: if node.type == 'R_LAYERS': @@ -198,8 +196,9 @@ for node in node_tree.nodes: render_layers_node = node break for link in node_tree.links: - if link.from_socket == feed_in_input: + if feed_in_input is not None and link.from_socket == feed_in_input: feed_in_output = link.to_socket + break from os import listdir from os.path import isfile, join @@ -278,7 +277,10 @@ 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]) -node_tree.links.new(set_alpha_node.outputs[0], feed_in_output) +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)` -- 2.30.2 From 7f79e7437128c4739efe03159bf1539dd0cffc23 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sat, 10 Aug 2024 15:53:10 -0400 Subject: [PATCH 10/18] Manager: replace os with pathlib --- .../job_compilers/scripts/single_image_render.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index c51dd82f..cd1918eb 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -200,22 +200,21 @@ for link in node_tree.links: feed_in_output = link.to_socket break -from os import listdir -from os.path import isfile, join +from pathlib import Path -root = "${path.join(renderDir, path.basename(renderOutput))}/######" -image_files = [f for f in listdir(root) if isfile(join(root, f))] +root = Path("${path.join(renderDir, path.basename(renderOutput))}/######") +image_files = [f for f in root.iterdir() if f.is_file()] separate_nodes = [] first_crop_node = None translate_nodes = [] for i, image_file in enumerate(image_files): image_node = node_tree.nodes.new('CompositorNodeImage') - image_node.image = bpy.data.images.load(root + '/' + image_file) + 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.split('-') + left, top, right, bottom = image_file.stem.split('-') if left == '0': crop_node.min_x = 0 crop_node.max_x = ${settings.tile_size_x} -- 2.30.2 From 6a51db78d73a239328cfdeae7e38bb13bb5156a0 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sun, 25 Aug 2024 21:55:00 -0400 Subject: [PATCH 11/18] Manager: fix tile sizes of non-integer portion of resolution --- .../scripts/single_image_render.js | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index cd1918eb..2466eb6a 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -208,6 +208,8 @@ 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)) @@ -215,25 +217,27 @@ for i, image_file in enumerate(image_files): 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 = ${settings.tile_size_x} + crop_node.max_x = actual_width else: crop_node.min_x = overscan - crop_node.max_x = ${settings.tile_size_x} + overscan + crop_node.max_x = actual_width + overscan if top == '0': crop_node.max_y = 0 - crop_node.min_y = ${settings.tile_size_y} + crop_node.min_y = actual_height else: crop_node.max_y = overscan - crop_node.min_y = ${settings.tile_size_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) + (${settings.tile_size_x} - ${settings.resolution_x}) / 2 - translate_node.inputs[2].default_value = float(top) + (${settings.tile_size_y} - ${settings.resolution_y}) / 2 + 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') @@ -245,8 +249,8 @@ for i, image_file in enumerate(image_files): scale_node = node_tree.nodes.new('CompositorNodeScale') scale_node.space = 'RELATIVE' -scale_node.inputs[1].default_value = ${settings.resolution_x} / ${settings.tile_size_x} -scale_node.inputs[2].default_value = ${settings.resolution_y} / ${settings.tile_size_y} +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' -- 2.30.2 From 3dd46d1c00084ca2f58915a7fa2f02bc7d1c96ab Mon Sep 17 00:00:00 2001 From: David Zhang Date: Tue, 27 Aug 2024 00:04:28 -0400 Subject: [PATCH 12/18] Manager: minor changes --- .../manager/job_compilers/scripts/single_image_render.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 2466eb6a..35b68474 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -149,7 +149,8 @@ function authorRenderTasks(settings, renderDir, renderOutput) { const task = author.Task(`render-${border[0]}-${border[1]}`, "blender"); let pythonExpr = `import bpy -render = bpy.context.scene.render +scene = bpy.context.scene +render = scene.render render.image_settings.file_format = 'OPEN_EXR_MULTILAYER' render.use_compositing = False overscan = 16 @@ -184,6 +185,8 @@ function authorMergeTask(settings, renderDir, renderOutput) { 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 node_tree = bpy.context.scene.node_tree @@ -281,9 +284,9 @@ 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) + 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.') + 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)` -- 2.30.2 From 43b9f7a0975f2e3c9fc9db759c7c7699ae06aa91 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Wed, 28 Aug 2024 23:07:03 -0400 Subject: [PATCH 13/18] Website: documentation for simple blender render --- .../content/usage/job-types/builtin.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 web/project-website/content/usage/job-types/builtin.md diff --git a/web/project-website/content/usage/job-types/builtin.md b/web/project-website/content/usage/job-types/builtin.md new file mode 100644 index 00000000..ad916c0f --- /dev/null +++ b/web/project-website/content/usage/job-types/builtin.md @@ -0,0 +1,23 @@ +--- +title: Built-in Job Types +weight: 10 +--- + +Flamenco comes with built-in job types that are used for most common tasks. Currently, there are two of them: + +- Simple Blender Render +- Single Image Render + +## Simple Blender Render + +This built-in job type is used for rendering a sequence of frames from a single Blender file, and potentially creating a preview video for compatible formats using FFmpeg. This job type is suitable for straightforward rendering tasks where one needs to render a range of frames and potentially compile them into a video. Note that this job type does not render into video formats directly, so the output format should be FFmpeg-compatible image formats. + +The job type defines several settings that can be configured when submitting a job: + +- `Frames` _string, required_: The frame range to render, e.g. '47', '1-30', '3, 5-10, 47-327'. It could also be set to use scene range or automatically determined on submission. +- `Chunk Size` _integer, default: 1_: Number of frames to render in one Blender render task. +- `Render Output Root` _string, required_: Base directory where render output is stored. Job-specific parts will be appended to this path. +- `Add Path Components` _integer, required, default: 0_: Number of path components from the current blend file to use in the render output path. +- `Render Output Path` _non-editable_: Final file path where render output will be saved. This is a computed value based on the `Render Output Root` and `Add Path Components` settings. + +By using this job type, you can easily distribute Blender rendering tasks across multiple workers in your Flamenco setup, potentially saving significant time on large rendering projects. -- 2.30.2 From aeafe0fc6d56417f20923a310d26602d51f66ab8 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 29 Aug 2024 00:19:01 -0400 Subject: [PATCH 14/18] Website: documentation for single image render --- .../content/usage/job-types/builtin.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/web/project-website/content/usage/job-types/builtin.md b/web/project-website/content/usage/job-types/builtin.md index ad916c0f..2cf60a6b 100644 --- a/web/project-website/content/usage/job-types/builtin.md +++ b/web/project-website/content/usage/job-types/builtin.md @@ -21,3 +21,20 @@ The job type defines several settings that can be configured when submitting a j - `Render Output Path` _non-editable_: Final file path where render output will be saved. This is a computed value based on the `Render Output Root` and `Add Path Components` settings. By using this job type, you can easily distribute Blender rendering tasks across multiple workers in your Flamenco setup, potentially saving significant time on large rendering projects. + +## Single Image Render + +This built-in job type is designed for distributed rendering of a single image from a Blender file. It splits the image into tiles, renders each tile separately, and then merges the tiles back into a single image. This approach allows for parallel processing of different parts of the image, potentially speeding up the rendering process. + +Currently, the job type supports composition, as long as there is one single `Render Layers` node. The job type does not support `Denoising` node. + +The job type defines several settings that can be configured when submitting a job: + +- `Tile Size X` _integer, default: 64: Tile size in pixels for the X axis, does not need to be divisible by the image width. +- `Tile Size Y` _integer, default: 64: Tile size in pixels for the Y axis, does not need to be divisible by the image height. +- `Frame` _integer, required_: The frame to render. By default, it uses the current frame in the Blender scene. +- `Render Output Root` _string, required_: Base directory where render output is stored. Job-specific parts will be appended to this path. +- `Add Path Components` _integer, required, default: 0_: Number of path components from the current blend file to use in the render output path. +- `Render Output Path` _non-editable_: Final file path where render output will be saved. This is a computed value based on the `Render Output Root` and `Add Path Components` settings. + +Choosing the right tile size is crucial for performance. Too small tiles might increase overhead, while too large tiles might not distribute the workload effectively. -- 2.30.2 From a5e0843c198f300909e4680c1b96e5b22f037ca5 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 29 Aug 2024 23:30:03 -0400 Subject: [PATCH 15/18] Manager: support stamp --- internal/manager/job_compilers/scripts/single_image_render.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 35b68474..98b353f5 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -153,6 +153,7 @@ 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} @@ -189,6 +190,7 @@ 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 -- 2.30.2 From be4a45f955cb55c995f0e702ec082930f80e1645 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 2 Sep 2024 07:02:44 -0400 Subject: [PATCH 16/18] Manager: change path name --- internal/manager/job_compilers/scripts/single_image_render.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 98b353f5..ccbc9356 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -41,7 +41,7 @@ const JOB_TYPE = { }, { 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}', '######'))", + 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" }, @@ -207,7 +207,7 @@ for link in node_tree.links: from pathlib import Path -root = Path("${path.join(renderDir, path.basename(renderOutput))}/######") +root = Path("${path.join(renderDir, path.basename(renderOutput))}/tiles") image_files = [f for f in root.iterdir() if f.is_file()] separate_nodes = [] -- 2.30.2 From 2843993de4e7f08e3db5b8c98a59a06efdb9ef4f Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 2 Sep 2024 07:19:21 -0400 Subject: [PATCH 17/18] Manager: does not support resolution scaling for now --- .../job_compilers/scripts/single_image_render.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index ccbc9356..4fc2e5ab 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -84,6 +84,14 @@ const JOB_TYPE = { visible: "hidden", description: "Resolution Y" }, + { + key: "resolution_scale", + type: "int32", + required: true, + eval: "C.scene.render.resolution_percentage", + visible: "hidden", + description: "Resolution scale" + } ] }; @@ -94,6 +102,10 @@ function compileJob(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; -- 2.30.2 From 494ffe13a2384a39e9d9ad8d9906f82151d6f8ac Mon Sep 17 00:00:00 2001 From: David Zhang Date: Mon, 2 Sep 2024 11:11:01 -0400 Subject: [PATCH 18/18] Manager: explanatory comments --- .../job_compilers/scripts/single_image_render.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/manager/job_compilers/scripts/single_image_render.js b/internal/manager/job_compilers/scripts/single_image_render.js index 4fc2e5ab..38ea28b6 100644 --- a/internal/manager/job_compilers/scripts/single_image_render.js +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -142,6 +142,8 @@ function renderOutputPath(job) { }); } +// 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) { @@ -159,6 +161,7 @@ function authorRenderTasks(settings, renderDir, renderOutput) { 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 @@ -195,6 +198,16 @@ bpy.ops.render.render(write_still=True)` 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 -- 2.30.2