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..38ea28b6 --- /dev/null +++ b/internal/manager/job_compilers/scripts/single_image_render.js @@ -0,0 +1,334 @@ +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; +} 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..2cf60a6b --- /dev/null +++ b/web/project-website/content/usage/job-types/builtin.md @@ -0,0 +1,40 @@ +--- +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. + +## 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.