diff --git a/scripts/TalkingHeads Custom Render.js b/scripts/TalkingHeads Custom Render.js new file mode 100644 index 0000000..58ab0a6 --- /dev/null +++ b/scripts/TalkingHeads Custom Render.js @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +const JOB_TYPE = { + label: "TalkingHeads Custom Render", + description: "Render a sequence of frames, and create a preview video file", + settings: [ + // Settings for artists to determine: + { + key: "frames", + type: "string", + required: true, + eval: "f'{C.scene.frame_start}-{C.scene.frame_end}'", + evalInfo: { + showLinkButton: true, + description: "Scene frame range", + }, + description: "Frame range to render. Examples: '47', '1-30', '3, 5-10, 47-327'" + }, + { + key: "chunk_size", + type: "int32", + default: 1, + description: "Number of frames to render in one Blender render task", + visible: "submission" + }, + + // render_output_root + add_path_components determine the value of render_output_path. + { + key: "render_output_root", + type: "string", + subtype: "dir_path", + required: true, + default: '//', + 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), 'seq', jobname, jobname + '_######'))", + 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: "fps", + type: "float", + eval: "C.scene.render.fps / C.scene.render.fps_base", + visible: "hidden" + }, + { + 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: "has_previews", + type: "bool", + required: false, + eval: "C.scene.render.image_settings.use_preview", + visible: "hidden", + description: "Whether Blender will render preview images." + }, + { + key: "scene", + type: "string", + required: true, + eval: "C.scene.name", + visible: "web", + description: "Name of the scene to render." + }, + ] +}; + + +// Set of scene.render.image_settings.file_format values that produce +// files which FFmpeg is known not to handle as input. +const ffmpegIncompatibleImageFormats = new Set([ + "EXR", + "MULTILAYER", // Old CLI-style format indicators + "OPEN_EXR", + "OPEN_EXR_MULTILAYER", // DNA values for these formats. +]); + +// File formats that would cause rendering to video. +// This is not supported by this job type. +const videoFormats = ['FFMPEG', 'AVI_RAW', 'AVI_JPEG']; + +function compileJob(job) { + print("Blender Render job submitted"); + print("job: ", job); + + const settings = job.settings; + if (videoFormats.indexOf(settings.format) >= 0) { + throw `This job type only renders images, and not "${settings.format}"`; + } + + 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); + const videoTask = authorCreateVideoTask(settings, renderDir); + + for (const rt of renderTasks) { + job.addTask(rt); + } + if (videoTask) { + // If there is a video task, all other tasks have to be done first. + for (const rt of renderTasks) { + videoTask.addDependency(rt); + } + job.addTask(videoTask); + } +} + +// 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 authorRenderTasks(settings, renderDir, renderOutput) { + print("authorRenderTasks(", renderDir, renderOutput, ")"); + let renderTasks = []; + let chunks = frameChunker(settings.frames, settings.chunk_size); + + let baseArgs = []; + if (settings.scene) { + baseArgs = baseArgs.concat(["--scene", settings.scene]); + } + + for (let chunk of chunks) { + const task = author.Task(`render-${chunk}`, "blender"); + const command = author.Command("blender-render", { + exe: "{blender}", + exeArgs: "{blenderArgs}", + argsBefore: [], + blendfile: settings.blendfile, + args: baseArgs.concat([ + "--render-output", path.join(renderDir, path.basename(renderOutput)), + "--render-format", settings.format, + "--render-frame", chunk.replaceAll("-", ".."), // Convert to Blender frame range notation. + ]) + }); + task.addCommand(command); + renderTasks.push(task); + } + return renderTasks; +} + +function authorCreateVideoTask(settings, renderDir) { + const needsPreviews = ffmpegIncompatibleImageFormats.has(settings.format); + if (needsPreviews && !settings.has_previews) { + print("Not authoring video task, FFmpeg-incompatible render output") + return; + } + if (!settings.fps) { + print("Not authoring video task, no FPS known:", settings); + return; + } + + var frames = `${settings.frames}`; + if (frames.search(',') != -1) { + // Get the first and last frame from the list + const chunks = frameChunker(settings.frames, 1); + const firstFrame = chunks[0]; + const lastFrame = chunks.slice(-1)[0]; + frames = `${firstFrame}-${lastFrame}`; + } + + const stem = path.stem(settings.blendfile).replace('.flamenco', ''); + const outfile = path.join(renderDir, `${stem}-${frames}.mp4`); + const outfileExt = needsPreviews ? ".jpg" : settings.image_file_extension; + + const task = author.Task('preview-video', 'ffmpeg'); + const command = author.Command("frames-to-video", { + exe: "ffmpeg", + fps: settings.fps, + inputGlob: path.join(renderDir, `*${outfileExt}`), + outputFile: outfile, + args: [ + '-c:v', + 'h264_nvenc', + '-preset', + 'medium', + '-rc', + 'constqp', + '-qp', + '20', + '-g', + '18', + '-vf', + 'pad=ceil(iw/2)*2:ceil(ih/2)*2', + '-pix_fmt', + 'yuv420p', + '-r', + settings.fps, + '-y', // Be sure to always pass either "-n" or "-y". + ], + }); + task.addCommand(command); + + print(`Creating output video for ${settings.format}`); + return task; +} diff --git a/scripts/TalkingHeads cycles_optix_gpu.js b/scripts/TalkingHeads cycles_optix_gpu.js new file mode 100644 index 0000000..9a5b22c --- /dev/null +++ b/scripts/TalkingHeads cycles_optix_gpu.js @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +const JOB_TYPE = { + label: 'TalkingHeads Cycles OPTIX GPU', + description: + 'OPTIX GPU rendering + extra checkboxes for some experimental features + extra CLI args for Blender', + settings: [ + // Settings for artists to determine: + { + key: 'frames', + type: 'string', + required: true, + eval: "f'{C.scene.frame_start}-{C.scene.frame_end}'", + evalInfo: { + showLinkButton: true, + description: 'Scene frame range', + }, + description: "Frame range to render. Examples: '47', '1-30', '3, 5-10, 47-327'", + }, + { + key: 'chunk_size', + type: 'int32', + default: 1, + description: 'Number of frames to render in one Blender render task', + visible: 'submission', + }, + + // render_output_root + add_path_components determine the value of render_output_path. + { + key: 'render_output_root', + type: 'string', + subtype: 'dir_path', + required: true, + default: '//', + 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), 'seq', jobname, jobname + '_######'))", + description: 'Final file path of where render output will be saved', + }, + + { + key: 'experimental_gp3', + label: 'Experimental: GPv3', + description: 'Experimental Flag: Grease Pencil 3', + type: 'bool', + required: false, + }, + { + key: 'experimental_new_anim', + label: 'Experimental: Baklava', + description: 'Experimental Flag: New Animation Data-block', + type: 'bool', + required: false, + }, + + // Extra CLI arguments for Blender, for debugging purposes. + { + key: 'blender_args_before', + label: 'Blender CLI args: Before', + description: 'CLI arguments for Blender, placed before the .blend filename', + type: 'string', + required: false, + }, + { + key: 'blender_args_after', + label: 'After', + description: 'CLI arguments for Blender, placed after the .blend filename', + type: 'string', + required: false, + }, + + // Automatically evaluated settings: + { + key: 'blendfile', + type: 'string', + required: true, + description: 'Path of the Blend file to render', + visible: 'web', + }, + { + key: 'fps', + type: 'float', + eval: 'C.scene.render.fps / C.scene.render.fps_base', + visible: 'hidden', + }, + { + 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: 'has_previews', + type: 'bool', + required: false, + eval: 'C.scene.render.image_settings.use_preview', + visible: 'hidden', + description: 'Whether Blender will render preview images.', + }, + ], +}; + +// Set of scene.render.image_settings.file_format values that produce +// files which FFmpeg is known not to handle as input. +const ffmpegIncompatibleImageFormats = new Set([ + 'EXR', + 'MULTILAYER', // Old CLI-style format indicators + 'OPEN_EXR', + 'OPEN_EXR_MULTILAYER', // DNA values for these formats. +]); + +// File formats that would cause rendering to video. +// This is not supported by this job type. +const videoFormats = ['FFMPEG', 'AVI_RAW', 'AVI_JPEG']; + +function compileJob(job) { + print('Blender Render job submitted'); + print('job: ', job); + + const settings = job.settings; + if (videoFormats.indexOf(settings.format) >= 0) { + throw `This job type only renders images, and not "${settings.format}"`; + } + + 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); + const videoTask = authorCreateVideoTask(settings, renderDir); + + for (const rt of renderTasks) { + job.addTask(rt); + } + if (videoTask) { + // If there is a video task, all other tasks have to be done first. + for (const rt of renderTasks) { + videoTask.addDependency(rt); + } + job.addTask(videoTask); + } + + cleanupJobSettings(job.settings); +} + +// 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; + } + }); +} + +const enable_all_optix = ` +import bpy + +cycles_prefs = bpy.context.preferences.addons['cycles'].preferences +cycles_prefs.compute_device_type = 'OPTIX' +for dev in cycles_prefs.get_devices_for_type('OPTIX'): + dev.use = (dev.type != 'CPU') +`; + +const enable_experimental_common = ` +import bpy + +exp_prefs = bpy.context.preferences.experimental +`; + +function authorRenderTasks(settings, renderDir, renderOutput) { + print('authorRenderTasks(', renderDir, renderOutput, ')'); + + // Extra arguments for Blender. + const blender_args_before = shellSplit(settings.blender_args_before); + const blender_args_after = shellSplit(settings.blender_args_after); + + // More arguments for Blender, which will be the same for each task. + const task_invariant_args = [ + '--python-expr', + enable_all_optix, + '--python-expr', + "import bpy; bpy.context.scene.cycles.device = 'GPU'", + '--render-output', + path.join(renderDir, path.basename(renderOutput)), + '--render-format', + settings.format, + ].concat(blender_args_after); + + // Add any experimental flags. + { + let py_code_to_join = [enable_experimental_common]; + if (settings.experimental_gp3) { + py_code_to_join.push('exp_prefs.use_grease_pencil_version3 = True'); + } + if (settings.experimental_new_anim) { + py_code_to_join.push('exp_prefs.use_animation_baklava = True'); + } + + // If it's not just the common code, at least one flag was enabled. + if (py_code_to_join.length > 1) { + task_invariant_args.push('--python-expr'); + task_invariant_args.push(py_code_to_join.join('\n')); + } + } + + // Construct a task for each chunk. + let renderTasks = []; + let chunks = frameChunker(settings.frames, settings.chunk_size); + for (let chunk of chunks) { + const task = author.Task(`render-${chunk}`, 'blender'); + const command = author.Command('blender-render', { + exe: '{blender}', + exeArgs: '{blenderArgs}', + argsBefore: blender_args_before, + blendfile: settings.blendfile, + args: task_invariant_args.concat([ + '--render-frame', + chunk.replaceAll('-', '..'), // Convert to Blender frame range notation. + ]), + }); + task.addCommand(command); + renderTasks.push(task); + } + return renderTasks; +} + +function authorCreateVideoTask(settings, renderDir) { + const needsPreviews = ffmpegIncompatibleImageFormats.has(settings.format); + if (needsPreviews && !settings.has_previews) { + print('Not authoring video task, FFmpeg-incompatible render output'); + return; + } + if (!settings.fps) { + print('Not authoring video task, no FPS known:', settings); + return; + } + + const stem = path.stem(settings.blendfile).replace('.flamenco', ''); + const outfile = path.join(renderDir, `${stem}-${settings.frames}.mp4`); + const outfileExt = needsPreviews ? '.jpg' : settings.image_file_extension; + + const task = author.Task('preview-video', 'ffmpeg'); + const command = author.Command('frames-to-video', { + exe: 'ffmpeg', + fps: settings.fps, + inputGlob: path.join(renderDir, `*${outfileExt}`), + outputFile: outfile, + args: [ + '-c:v', + 'h264_nvenc', + '-preset', + 'medium', + '-rc', + 'constqp', + '-qp', + '20', + '-g', + '18', + '-vf', + 'pad=ceil(iw/2)*2:ceil(ih/2)*2', + '-pix_fmt', + 'yuv420p', + '-r', + settings.fps, + '-y', // Be sure to always pass either "-n" or "-y". + ], + }); + task.addCommand(command); + + print(`Creating output video for ${settings.format}`); + return task; +} + +// Clean up empty job settings so that they're no longer shown in the web UI. +function cleanupJobSettings(settings) { + const settings_to_check = [ + 'blender_args_before', + 'blender_args_after', + 'experimental_gp3', + 'experimental_new_anim', + ]; + + for (let setting_name of settings_to_check) { + if (!settings[setting_name]) delete settings[setting_name]; + } +}