---------------------------------------------------------------------------------------------------- -- General animation rendering, this just takes the scene as is and renders -- it at regular time intervals -- -- @author Octane Dev Team -- @description Renders an imported animation -- @version 0.4 -- @script-id OctaneRender render imported animation -- enum values TIME_AS_SECONDS , TIME_AS_FRAME = 1, 2 SHUTTER_AS_SECONDS, SHUTTER_AS_PERCENT = 1, 2 -- get from settings if we want frame numbers or time local prefs = octane.project.getPreferences() local projectSettings = octane.project.getProjectSettings() local timeDisplay = prefs:getAttribute(octane.A_TIME_DISPLAY) local shutterDisplay = prefs:getAttribute(octane.A_SHUTTER_TIME_DISPLAY) -- utility functions -- these are all settings for which we use binding local defaultBindSettings = { sceneFps = projectSettings:getAttribute(octane.A_FRAMES_PER_SECOND), samples = 400, useFileNumber = false, fileNumber = 0, skipExisting = true, outputDirectory = "", fileTemplate = "frame_%f_%p.%e", saveRenderPasses = true, fileType = octane.render.imageType.PNG8, useMultiLayerExr = false } local bindSettings = octane.gui.createPropertyTable() -- settings store on disk local settings = octane.storage.app -- apply default settings for k, v in pairs(defaultBindSettings) do if settings[k] == nil then settings[k] = v end end -- GUI code -- helper function to quickly update the ranges of a slider local function setSliderRange(slider, value, min, max, step) slider:updateProperties { value = value, -- value of the slider minValue = min, -- minimum value of the slider maxValue = max, -- maximum value of the slider step = step, -- interval between 2 discrete slider values } end local parameters = {} local fpsLbl , fpsSlider = octane.gui.createParameter(parameters, "Framerate" , settings.sceneFps, 1, 120, .1, true ) local samplesLbl, samplesSlider = octane.gui.createParameter(parameters, "Samples/px", settings.samples , 1, 16000, 1, true ) local startLbl , startSlider = octane.gui.createParameter(parameters) local endLbl , endSlider = octane.gui.createParameter(parameters) local shutterLbl, shutterSlider = octane.gui.createParameter(parameters) samplesSlider:bind("value", bindSettings, "samples") fpsSlider:bind ("value", bindSettings, "sceneFps") -- manual layouting is tedious so let's add all our stuff in a group. local settingsGrp = octane.gui.createGroup { text = "Settings", padding = { 2 }, inset = { 5 }, children = parameters } -- file output -- some globals used later fileGrpChildren = nil fileChooseButton = nil fileEditor = nil -- if we can save an image if octane.render.saveImage then fileTemplateLbl = octane.gui.createLabel{ text = "Filename template", width = 120 } fileTemplateEditor = octane.gui.createTextEditor { text = settings.fileTemplate, tooltip = "Template parameters: %f frame number %p render pass name %e extension", width = settingsGrp.width - fileTemplateLbl.width - 35, height = 20, x = 10, } fileTemplateEditor:bind("text", bindSettings, "fileTemplate") local fileTemplateGrp = octane.gui.createGroup { border = false, children = { { fileTemplateLbl, fileTemplateEditor } }, inset = { 0 }, padding = { 0 }, } -- create a button to show a file chooser fileChooseButton = octane.gui.createButton{ text = "Output folder...", width = 120 } -- create an editor that will show the chosen file path fileEditor = octane.gui.createTextEditor { text = settings.outputDirectory, width = fileTemplateEditor.width, height = 20, x = 10 } fileEditor:bind("text", bindSettings, "outputDirectory") -- group them together local fileChooseGrp = octane.gui.createGroup { border = false, children = { { fileChooseButton, fileEditor } }, inset = { 0 }, padding = { 0 }, } -- file numbering startnrNum = octane.gui.createNumericBox { minValue = 0, maxValue = 10000, value = settings.fileNumber, step = 1} startnrChk = octane.gui.createCheckBox { text = "Start file numbering at:" , checked = settings.useFileNumber , width = 150,height = 20} local startNrGrp = octane.gui.createGroup { border = false, children = { { startnrChk, startnrNum } } } skipExistingChk = octane.gui.createCheckBox { text = "Skip existing image files" , checked = settings.skipExisting , width = 250,height = 20,x=7} saveRenderPassesChk = octane.gui.createCheckBox { text = "Save all enabled render passes", checked = settings.saveRenderPasses, width = 250,height = 20,x=7} multiLayerExrChk = octane.gui.createCheckBox { text = "Use multi-layer EXR" , checked = settings.useMultiLayerExr, width = 250,height = 20,x=27} multiLayerExrChk.enable = (settings.fileType == octane.render.imageType.EXR) startnrChk:bind("checked" , bindSettings, "useFileNumber") startnrNum:bind("value" , bindSettings, "fileNumber" ) multiLayerExrChk:bind("checked" , bindSettings, "useMultiLayerExr") skipExistingChk:bind("checked" , bindSettings, "skipExisting" ) saveRenderPassesChk:bind("checked", bindSettings, "saveRenderPasses") local fileComboIx = 0 if settings.fileType == octane.render.imageType.PNG8 then fileComboIx = 1 end if settings.fileType == octane.render.imageType.PNG16 then fileComboIx = 2 end if settings.fileType == octane.render.imageType.EXR then fileComboIx = 3 end fileTypeCombo = octane.gui.createComboBox { x = 7, items = { "PNG (8-bit)", "PNG (16-bit)", "EXR", }, selectedIx = fileComboIx, width = 120, callback = function(combo) -- update the file type for this render target in the settings local fileTypes = { octane.render.imageType.PNG8, octane.render.imageType.PNG16, octane.render.imageType.EXR, } bindSettings.fileType = fileTypes[combo.selectedIx] if (bindSettings.fileType == octane.render.imageType.EXR) then multiLayerExrChk.enable = true else multiLayerExrChk.enable = false end end } fileGrpChildren = { fileTemplateGrp, fileChooseGrp, startNrGrp, skipExistingChk, saveRenderPassesChk, fileTypeCombo, multiLayerExrChk } else local label = createLabel("Saving images is not available in the demo version") label.width = 500 fileGrpChildren = { label } end -- for layouting the button and the editor we use a group local fileGrp = octane.gui.create { type = octane.gui.componentType.GROUP, text = "Output", rows = #fileGrpChildren, cols = 1, children = fileGrpChildren, padding = { 2 }, inset = { 5 }, } -- eye candy, a progress bar local progressBar = octane.gui.createProgressBar { text = "render progress", width = fileGrp.width - 4, height = 20 } -- for layouting the progress bar local progressGrp = octane.gui.createGroup { type = octane.gui.componentType.GROUP, text = "", children = { { progressBar } }, padding = { 0 }, inset = { 0 }, centre = true, -- centre the progress bar in it's cell border = false } -- render & cancel buttons local RENDERTXT = "Start render" local STOPTXT = "Stop render" local EXITTXT = "Exit" local renderButton = octane.gui.createButton("Start render") local exitButton = octane.gui.createButton("Exit") local buttonGrp = octane.gui.createGroup { text = "", children = { { renderButton, exitButton } }, padding = { 5 }, border = false } -- group that layouts the other groups local layoutGrp = octane.gui.createGroup { children = { { settingsGrp }, { fileGrp }, { progressGrp }, { buttonGrp } }, centre = true, padding = { 2 }, border = false, debug = false -- true to show the outlines of the group, handy } -- window that holds all components local animationWindow = octane.gui.create { type = octane.gui.componentType.WINDOW, text = "Render animation", children = { layoutGrp }, width = layoutGrp.width, height = layoutGrp.height, } ---------------------------------------------------------------------------------------------------- -- Animation Rendering Code (helpers to get us going) -- Returns copies of the scene graph, the render target node and the animation time span. This way -- the user cannot modify the original scene via this script. local function getSceneCopy() -- get the selected render target local selectedRt = octane.project.getSelection()[1] if not selectedRt or selectedRt.type ~= octane.NT_RENDERTARGET then octane.gui.showError("No render target selected") return nil end -- Create a full copy of the whole project so we don't modify the original project. local copyScene = octane.nodegraph.createRootGraph("Project Copy") local copyRt = copyScene:copyFromGraph(octane.project.getSceneGraph(), { selectedRt })[1] -- check if the copied node is a render target with a thinlens camera connected to it if not copyRt or copyRt.type ~= octane.NT_RENDERTARGET then octane.gui.showError("No render target selected") return nil end -- check if we actually have an animation local interval = copyScene:getAnimationTimeSpan() if interval[1] >= interval[2] then octane.gui.showError("This scene does not have any animation") return nil end return copyScene, copyRt, interval end -- cancel flag and other globals IDLE, RENDERING = 1, 2 status = IDLE startFrame = 0 endFrame = 0 dT = 0 currentFrame = 0 nbFrames = 0 -- Render callback function renderCallback(result) if status == IDLE then octane.render.callbackStop() return end local t = result.samples / bindSettings.samples t = (t + currentFrame - startFrame) / nbFrames progressBar.progress = t end local function readGui() -- shutter speed settings.sceneShutter = shutterSlider.value if shutterDisplay == SHUTTER_AS_PERCENT then settings.sceneShutter = settings.sceneShutter / 100 * dT end end -- Returns the names of all enabled render passes. local function createRenderPassExportObjs(renderPassesNode) local objs = {} for _, id in ipairs(octane.render.getAllRenderPassIds()) do local info = octane.render.getRenderPassInfo(id) if info.pinId ~= octane.P_UNKNOWN then if renderPassesNode:getPinValue(info.pinId) then local exportObj = { ["exportName"] = nil, ["origName"] = info.name, ["renderPassId"] = info.renderPassId, } table.insert(objs, exportObj) end else local exportObj = { ["exportName"] = nil, ["origName"] = info.name, ["renderPassId"] = info.renderPassId, } table.insert(objs, exportObj) end end return objs end local function startRender() if endFrame < startFrame then octane.gui.showError("No frames to render") return end status = RENDERING readGui() octane.render.setShutterTime(settings.sceneShutter) -- set the max samples value of the kernel input node to make sure -- it's not less than the current setting local kernel = rtNode:getInputNode(octane.P_KERNEL) if kernel ~= nil then kernel:setPinValue(octane.P_MAX_SAMPLES, bindSettings.samples, false) end -- if we need to save all render pass, create the render pass export objs local renderPassExportObjs = {} if bindSettings.saveRenderPasses then local rpNode = rtNode:getInputNode(octane.P_RENDER_PASSES) if rpNode ~= nil then rpNode:setPinValue(octane.P_RENDER_PASS_INFO_AFTER_BEAUTY, false) renderPassExportObjs = createRenderPassExportObjs(rpNode) end end -- start rendering out each frame local nbRendered = 0 local nbSkipped = 0 local nbFailed = 0 for seq = 1,nbFrames do -- time and sequence number currentFrame = startFrame + seq - 1 local text = string.format("Frame %d (%d/%d)", currentFrame, seq, nbFrames) if nbFailed ~= 0 then text = text.." ("..nbFailed.." failed)" end progressBar.text = text -- create filename or render pass export objects local fileFrameNb = currentFrame if bindSettings.useFileNumber then fileFrameNb = seq - 1 + bindSettings.fileNumber end local placeholders = { ["f"] = string.format("%04d", fileFrameNb), ["p"] = "Beauty", ["e"] = "png", } if bindSettings.fileType == octane.render.imageType.EXR then placeholders.e = "exr" end local filename = nil local saveImages = (bindSettings.fileTemplate and bindSettings.outputDirectory ~= "") -- figure out where and what to save, if the user has given a directory and file name if saveImages then if bindSettings.saveRenderPasses then -- multi-layer EXR if bindSettings.fileType == octane.render.imageType.EXR and bindSettings.useMultiLayerExr then for ix, exportObj in ipairs(renderPassExportObjs) do exportObj.exportName = exportObj.origName end placeholders.p = "all" filename = octane.file.resolveTemplate(bindSettings.fileTemplate, placeholders) filename = octane.file.join(bindSettings.outputDirectory, filename) -- discrete file else for ix, exportObj in ipairs(renderPassExportObjs) do placeholders.p = exportObj.origName exportObj.exportName = octane.file.resolveTemplate(bindSettings.fileTemplate, placeholders) end end else filename = octane.file.resolveTemplate(bindSettings.fileTemplate, placeholders) filename = octane.file.join(bindSettings.outputDirectory, filename) end -- figure out if we need to skip existing frames local skipFrame = false if filename and bindSettings.skipExisting and octane.file.exists(filename) then skipFrame = true end if not filename and bindSettings.skipExisting then skipFrame = true for ix, exportObj in ipairs(renderPassExportObjs) do local testFile = octane.file.join(bindSettings.outputDirectory, exportObj.exportName) if not octane.file.exists(testFile) then skipFrame = false break end end end end -- render frame if the file doesn't exist yet (or no file is given), or always if -- we want to overwrite if not skipFrame then -- set the time in the scene copyScene:updateTime(interval[1] + dT * currentFrame) -- do the rendering local finished = pcall(octane.render.start, { renderTargetNode = rtNode, callback = renderCallback, }) if not finished then nbFailed = nbFailed + 1 end -- break out if we're cancelled and set it in the progress bar if status == IDLE then progressBar:updateProperties{ progress = 0, text = "Cancelled" } break -- save on disk elseif saveImages then if bindSettings.saveRenderPasses then if bindSettings.fileType == octane.render.imageType.EXR and bindSettings.useMultiLayerExr then octane.render.saveRenderPassesMultiExr(filename, renderPassExportObjs) else octane.render.saveRenderPasses(bindSettings.outputDirectory, renderPassExportObjs, bindSettings.fileType) end else octane.render.saveImage(filename, bindSettings.fileType) end end nbRendered = nbRendered + 1 else nbSkipped = nbSkipped + 1 end -- update the progress bar progressBar.progress = seq / nbFrames end -- update the progress bar if status ~= IDLE then if nbRendered == 0 and nbSkipped > 0 then progressBar:updateProperties{ text = "All image files already exist", progress = 0 } else local text = "Finished" if nbFailed ~= 0 then text = text.." ("..nbFailed.." failed)" end progressBar.text = text end end status = IDLE end local function cancelRender() status = IDLE octane.render.callbackStop() end local function initGui(interval) -- scene settings if not settings.sceneShutter then settings.sceneShutter = octane.render.getShutterTime() end if not settings.startTime then settings.startTime = interval[1] end if not settings.endTime then settings.endTime = interval[2] end -- lets create a bunch of labels and sliders local timeStr = (timeDisplay == TIME_AS_SECONDS) and "time" or "frame" startLbl.text = "Start "..timeStr endLbl .text = "End "..timeStr local shutterUnit = (shutterDisplay == SHUTTER_AS_SECONDS) and "sec" or "%" shutterLbl.text = "Shutter time ("..shutterUnit..")" dT = 1 / bindSettings.sceneFps if timeDisplay == TIME_AS_SECONDS then setSliderRange(startSlider, settings.startTime, interval[1], interval[2], dT) setSliderRange(endSlider, settings.endTime, interval[1], interval[2], dT) else -- for calculating the total frame count we have to consider rounding -- errors. The start and end sliders are inclusive bounds, so eg. with 24 -- FPS an interval of [3/24, 5/24] will render 3 frames. nbFrames = math.max(0, 1 + math.floor((interval[2] - interval[1] + .0001*dT) * bindSettings.sceneFps)) local startFrame = math.floor(.5 + (settings.startTime - interval[1]) * bindSettings.sceneFps) local endFrame = math.floor(.5 + (settings.endTime - interval[1]) * bindSettings.sceneFps) setSliderRange(startSlider, startFrame, 0, nbFrames - 1, 1) setSliderRange(endSlider , endFrame , 0, nbFrames - 1, 1) end if shutterDisplay == SHUTTER_AS_SECONDS then setSliderRange(shutterSlider, settings.sceneShutter, 0, 1 , .0001) else setSliderRange(shutterSlider, settings.sceneShutter * 100 * bindSettings.sceneFps, 0, 1000 , .1) end end ------------------------------------------------------------------------------ -- Main Flow -- copy the scene, so we don't modify the original scene. copyScene, rtNode, interval = getSceneCopy() -- if getSceneCopy failed, halt the script if rtNode == nil then return end -- initialize the UI initGui(interval) inputwidgets = { startSlider , endSlider , fpsSlider , shutterSlider, samplesSlider, -- all components below are file input widgets and may be nil, in Lua this means -- we have a smaller array fileTemplateLbl , fileTemplateEditor , fileChooseButton , fileEditor , startnrChk , startnrNum , skipExistingChk , saveRenderPassesChk, fileTypeCombo , multiLayerExrChk } -- callback handling the GUI elements function guiCallback(component, event) if event == octane.gui.eventType.BUTTON_CLICK then if component == fileChooseButton then -- choose an output directory local ret = octane.gui.showDialog { type = octane.gui.dialogType.FILE_DIALOG, title = "Choose the output folder", path = bindSettings.outputDirectory, browseDirectory = true, save = false, } -- if a file is chosen if ret.result ~= "" then fileEditor.text = ret.result end elseif component == renderButton then if status == IDLE then renderButton.text = STOPTXT for _, c in pairs(inputwidgets) do c.enable = false end -- start render startRender() renderButton.text = RENDERTXT for _, c in pairs(inputwidgets) do c.enable = true end elseif status == RENDERING then cancelRender() end elseif component == exitButton then animationWindow:closeWindow() end elseif event == octane.gui.eventType.WINDOW_CLOSE then cancelRender() end end function updateFramesCallback(component, event) dT = 1 / bindSettings.sceneFps -- if the fps change, and the time sliders are using frame numbers, we have to update the bounds if component == fpsSlider and event == octane.gui.eventType.VALUE_CHANGE then if timeDisplay == TIME_AS_FRAME then sceneframes = math.max(0, 1 + math.floor((interval[2] - interval[1] + .0001*dT) * bindSettings.sceneFps)) startSlider.maxValue = sceneframes - 1 startSlider.value = math.min(startSlider.value, sceneframes - 1) endSlider .maxValue = sceneframes - 1 endSlider.value = math.min(endSlider.value, sceneframes - 1) else startSlider.step = dT endSlider.step = dT end end -- for calculating the total frame count we have to consider rounding -- errors. The start and end sliders are inclusive bounds, so eg. with 24 -- FPS an interval of [3/24, 5/24] will render 3 frames. startFrame = startSlider.value endFrame = endSlider.value if timeDisplay == TIME_AS_SECONDS then settings.startTime = startFrame settings.endTime = endFrame startFrame = math.floor((startFrame - interval[1] + .0001*dT) * bindSettings.sceneFps) endFrame = math.floor((endFrame - interval[1] + .0001*dT) * bindSettings.sceneFps) else settings.startTime = interval[1] + startFrame * dT settings.endTime = interval[1] + endFrame * dT end nbFrames = 1 + endFrame - startFrame progressBar.text="Frames "..startFrame.."–"..endFrame.." ("..nbFrames..")" if event == octane.gui.eventType.VALUE_CHANGE then if component == startSlider then if endFrame < startFrame then endFrame = startFrame endSlider.value = startSlider.value end elseif component == endSlider then if endFrame < startFrame then startFrame = endFrame startSlider.value = endSlider.value end end end end -- hookup the callback with all the GUI elements if fileChooseButton then fileChooseButton.callback = guiCallback end renderButton.callback = guiCallback exitButton.callback = guiCallback animationWindow.callback = guiCallback animationWindow.callback = guiCallback startSlider.callback = updateFramesCallback endSlider.callback = updateFramesCallback fpsSlider.callback = updateFramesCallback updateFramesCallback() -- show dialog animationWindow:showWindow() -- save settings back to disk readGui() for k, _ in pairs(defaultBindSettings) do settings[k] = bindSettings[k] end -- reset the render engine octane.render.reset()