// 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)}`; }