Files
Flamenco-Management/scripts/TalkingHeads cycles_optix_gpu.js
Raincloud 51f1390c96 script revisions FINAL
- automatically determines Renders, Submodules, and Daily folder
2025-08-13 15:57:45 -06:00

465 lines
15 KiB
JavaScript

// 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',
},
{
key: 'render_output_root',
type: 'string',
subtype: 'dir_path',
required: false,
visible: 'submission',
eval:
"__import__('os').path.normpath(__import__('os').path.join(((__import__('re').search(r'^(.*?)[\\/][Bb]lends[\\/]', bpy.data.filepath.replace('\\\\','/')) and __import__('re').search(r'^(.*?)[\\/][Bb]lends[\\/]', bpy.data.filepath.replace('\\\\','/')).group(1)) or __import__('os').path.dirname(bpy.data.filepath)), 'Renders'))",
evalInfo: { showLinkButton: true, description: "Auto-detect the project's Renders folder" },
description:
"Base path where renders are stored, typically the project's Renders folder. If empty, derived automatically.",
},
{
key: 'daily_path',
type: 'string',
required: false,
visible: 'submission',
eval: "__import__('datetime').datetime.now().strftime('daily_%y%m%d')",
evalInfo: { showLinkButton: true, description: "Auto-fill with today's daily folder name" },
description:
"Daily folder name under the render root, e.g. 'daily_250813'. If empty, auto-fills to today's date.",
},
{
key: 'use_submodule',
label: 'Use Submodule',
type: 'bool',
required: false,
default: false,
visible: 'submission',
description: 'Include a submodule folder under Renders. Turn off to omit submodule entirely.',
},
{
key: 'submodule',
type: 'string',
required: false,
visible: 'submission',
eval: "(__import__('os').path.basename(__import__('os').path.dirname(bpy.data.filepath)) if settings.use_submodule else '')",
evalInfo: { showLinkButton: true, description: "Auto-fill with the current .blend file's parent folder" },
description:
"Optional submodule under Renders (e.g. 'Waterspider B'). If empty, omitted.",
},
{
key: 'render_output_path',
type: 'string',
subtype: 'file_path',
editable: false,
eval: "str(Path(abspath(settings.render_output_root or '//'), ((str(settings.submodule or '').strip()) if (settings.use_submodule and str(settings.submodule or '').strip()) else ((__import__('os').path.basename(__import__('os').path.dirname(bpy.data.filepath))) if settings.use_submodule else '')), (settings.daily_path or __import__('datetime').datetime.now().strftime('daily_%y%m%d')), 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,
eval: 'bpy.data.filepath',
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;
// Ensure auto-filled values are applied at submission time.
try {
if (settings.use_submodule) {
const detectedSubmodule = detectSubmodule(settings) || '';
if (!settings.submodule || String(settings.submodule).trim() === '') {
settings.submodule = detectedSubmodule;
}
} else {
settings.submodule = '';
}
if (!settings.render_output_root || String(settings.render_output_root).trim() === '') {
const projectRoot = findProjectRootFromBlendfile(settings.blendfile);
if (projectRoot) settings.render_output_root = path.join(projectRoot, 'Renders');
else settings.render_output_root = path.join(path.dirname(settings.blendfile), 'Renders');
}
if (!settings.daily_path || String(settings.daily_path).trim() === '') {
const createdDate = job && job.created ? new Date(job.created) : new Date();
settings.daily_path = formatDailyYYMMDD(createdDate);
}
const recomposed = computeAutoRenderOutputPath(job);
if (recomposed) settings.render_output_path = recomposed;
} catch (e) {
print('Auto-fill on submit failed:', e);
}
if (videoFormats.indexOf(settings.format) >= 0) {
throw `This job type only renders images, and not "${settings.format}"`;
}
const renderOutput = normalizePathSeparators(settings.render_output_path || 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;
}
});
}
// Ensure consistent separators for server-side consumption.
function normalizePathSeparators(p) {
if (!p) return p;
const forward = p.replace(/\\/g, '/');
// Collapse multiple slashes but preserve drive letter paths like 'A:/'
return forward.replace(/([^:])\/+/g, '$1/');
}
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];
}
}
// Derive project root, submodule, and daily path from the blendfile path.
function computeAutoRenderOutputPath(job) {
const settings = job.settings || {};
if (!settings.blendfile) return null;
const projectRoot = findProjectRootFromBlendfile(settings.blendfile);
const submodule = detectSubmodule(settings);
// Resolve render root
let renderRoot = null;
if (settings.render_output_root && ("" + settings.render_output_root).trim()) {
renderRoot = ("" + settings.render_output_root).trim();
} else if (projectRoot) {
renderRoot = path.join(projectRoot, 'Renders');
} else {
renderRoot = path.join(path.dirname(settings.blendfile), 'Renders');
}
// Resolve daily path
let daily = null;
if (settings.daily_path && ("" + settings.daily_path).trim()) {
daily = ("" + settings.daily_path).trim();
} else {
const createdDate = job && job.created ? new Date(job.created) : new Date();
daily = formatDailyYYMMDD(createdDate);
}
const jobname = job && job.name ? job.name : path.stem(settings.blendfile).replace('.flamenco', '');
const parts = [renderRoot];
if (submodule) parts.push(submodule);
parts.push(daily, jobname, `${jobname}_######`);
return path.join.apply(path, parts);
}
function findProjectRootFromBlendfile(blendfilePath) {
const blendDir = path.dirname(blendfilePath);
const normalized = blendDir.replace(/\\/g, '/');
const parts = normalized.split('/');
let blendsIndex = -1;
for (let i = 0; i < parts.length; i++) {
if (parts[i].toLowerCase() === 'blends') {
blendsIndex = i;
break;
}
}
if (blendsIndex <= 0) return null;
const rootParts = parts.slice(0, blendsIndex);
if (rootParts.length === 0) return null;
return rootParts.join('/');
}
function detectSubmoduleFromBlendfile(blendfilePath) {
const blendDir = path.dirname(blendfilePath);
const normalized = blendDir.replace(/\\/g, '/');
const parts = normalized.split('/');
for (let i = 0; i < parts.length; i++) {
if (parts[i].toLowerCase() === 'blends') {
if (i + 1 < parts.length && parts[i + 1].toLowerCase() === 'animations') {
if (i + 2 < parts.length) return parts[i + 2];
}
break;
}
}
return null;
}
// Prefer explicit setting; else detect robustly from blendfile path.
function detectSubmodule(settings) {
if (!settings) return null;
if (settings.submodule && ("" + settings.submodule).trim()) {
return ("" + settings.submodule).trim();
}
const bf = settings.blendfile || '';
try {
const m = bf.replace(/\\/g, '/').match(/\/(?:[Bb]lends)\/(?:[Aa]nimations)\/([^\/]+)/);
if (m && m[1]) return m[1];
} catch (_) {}
return detectSubmoduleFromBlendfile(bf);
}
function formatDailyYYMMDD(dateObj) {
const pad2 = (n) => (n < 10 ? '0' + n : '' + n);
const yy = dateObj.getFullYear() % 100;
const mm = dateObj.getMonth() + 1;
const dd = dateObj.getDate();
return `daily_${pad2(yy)}${pad2(mm)}${pad2(dd)}`;
}