2025-08-13 12:59:08 -06:00
// 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' ,
2025-08-13 15:57:45 -06:00
required : false ,
2025-08-13 12:59:08 -06:00
visible : 'submission' ,
2025-08-13 15:57:45 -06:00
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" } ,
2025-08-13 12:59:08 -06:00
description :
2025-08-13 15:57:45 -06:00
"Base path where renders are stored, typically the project's Renders folder. If empty, derived automatically." ,
2025-08-13 12:59:08 -06:00
} ,
{
2025-08-13 15:57:45 -06:00
key : 'daily_path' ,
type : 'string' ,
required : false ,
2025-08-13 12:59:08 -06:00
visible : 'submission' ,
2025-08-13 15:57:45 -06:00
eval : "__import__('datetime').datetime.now().strftime('daily_%y%m%d')" ,
evalInfo : { showLinkButton : true , description : "Auto-fill with today's daily folder name" } ,
2025-08-13 12:59:08 -06:00
description :
2025-08-13 15:57:45 -06:00
"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." ,
2025-08-13 12:59:08 -06:00
} ,
{
key : 'render_output_path' ,
type : 'string' ,
subtype : 'file_path' ,
editable : false ,
2025-08-13 15:57:45 -06:00
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 + '_######'))" ,
2025-08-13 12:59:08 -06:00
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 ,
2025-08-13 15:57:45 -06:00
eval : 'bpy.data.filepath' ,
2025-08-13 12:59:08 -06:00
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 ;
2025-08-13 15:57:45 -06:00
// 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 ) ;
}
2025-08-13 12:59:08 -06:00
if ( videoFormats . indexOf ( settings . format ) >= 0 ) {
throw ` This job type only renders images, and not " ${ settings . format } " ` ;
}
2025-08-13 15:57:45 -06:00
const renderOutput = normalizePathSeparators ( settings . render _output _path || renderOutputPath ( job ) ) ;
2025-08-13 12:59:08 -06:00
// 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 ;
}
} ) ;
}
2025-08-13 15:57:45 -06:00
// 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/' ) ;
}
2025-08-13 12:59:08 -06:00
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 ] ;
}
}
2025-08-13 15:57:45 -06:00
// 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 ) } ` ;
}