415 lines
14 KiB
JavaScript
415 lines
14 KiB
JavaScript
// 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"
|
|
},
|
|
// Ensure blendfile is available for subsequent auto-evals
|
|
{
|
|
key: "blendfile",
|
|
type: "string",
|
|
required: true,
|
|
eval: "bpy.data.filepath",
|
|
description: "Path of the Blend file to render",
|
|
visible: "web"
|
|
},
|
|
|
|
{
|
|
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"
|
|
},
|
|
|
|
// Automatically evaluated settings:
|
|
{
|
|
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;
|
|
|
|
// 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() === '') {
|
|
// Auto-detect project root and Renders folder similar to eval button
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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 = (settings.submodule && ("" + settings.submodule).trim()) ? ("" + settings.submodule).trim() : 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 {
|
|
// Fallback to the blendfile's directory Renders sibling
|
|
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', '');
|
|
|
|
print('AutoPath: blendfile=', settings.blendfile);
|
|
print('AutoPath: projectRoot=', projectRoot);
|
|
print('AutoPath: renderRoot=', renderRoot);
|
|
print('AutoPath: submodule=', submodule);
|
|
print('AutoPath: daily=', daily);
|
|
print('AutoPath: jobname=', jobname);
|
|
|
|
const parts = [renderRoot];
|
|
if (submodule) parts.push(submodule);
|
|
parts.push(daily, jobname, `${jobname}_######`);
|
|
const finalPath = path.join.apply(path, parts);
|
|
print('AutoPath: finalPath=', finalPath);
|
|
return finalPath;
|
|
}
|
|
|
|
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 regex first (case-insensitive): /Blends/animations/<name>/...
|
|
try {
|
|
const bfNorm = bf.replace(/\\/g, '/');
|
|
const m = bfNorm.match(/\/(?:[Bb]lends)\/(?:[Aa]nimations)\/([^\/]+)/);
|
|
print('detectSubmodule: bf=', bfNorm, ' match=', m && m[1]);
|
|
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)}`;
|
|
}
|
|
|
|
// 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/');
|
|
}
|
|
|
|
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;
|
|
}
|