---------------------------------------------------------------------------------------------------- -- 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.3 -- @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 -- creates a text label and returns it local function createLabel(text) return octane.gui.create { type = octane.gui.componentType.LABEL, -- type of component text = text, -- text that appears on the label width = 100, -- width of the label in pixels height = 24, -- height of the label in pixels } end -- creates a slider and returns it local function createSlider(value, min, max, step, log) return octane.gui.create { type = octane.gui.componentType.SLIDER, -- type of the component width = 400, -- width of the slider in pixels height = 20, -- height of the slider in pixels 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 logarithmic = log -- set the slider logarithmic } end 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 -- helper to pop-up an error dialog local function showError(text) octane.gui.showDialog { type = octane.gui.dialogType.BUTTON_DIALOG, title = "Animation Error", text = text, } end -- lets create a bunch of labels and sliders local startLbl = createLabel() local endLbl = createLabel() local fpsLbl = createLabel("Framerate") local shutterLbl = createLabel() local samplesLbl = createLabel("Samples/px") startSlider = createSlider() endSlider = createSlider() shutterSlider = createSlider() fpsSlider = createSlider(settings.sceneFps, 1, 120 , .1, true) fpsSlider:bind("value", bindSettings, "sceneFps") samplesSlider = createSlider(settings.samples , 1, 16000, 1, true) samplesSlider:bind("value", bindSettings, "samples") -- manual layouting is tedious so let's add all our stuff in a group. local settingsGrp = octane.gui.create { type = octane.gui.componentType.GROUP, -- type of component text = "Settings", -- title for the group rows = 5, -- number of rows in the grid cols = 2, -- number of colums in the grid -- the children is a list of child component that go in each cell. The cells -- are filled left to right, top to bottom. I just formatted the list to show -- where each component goes in the grid. children = { startLbl , startSlider , endLbl , endSlider , fpsLbl , fpsSlider , shutterLbl , shutterSlider , samplesLbl , samplesSlider , }, padding = { 2 }, -- internal padding in each cell inset = { 5 }, -- inset of the group component itself } -- file output local fileGrpChildren = nil local fileChooseButton = nil fileEditor = nil if octane.render.saveImage then fileTemplateLbl = octane.gui.create { type = octane.gui.componentType.LABEL, text = "Filename template", width = 120, height = 20, } fileTemplateEditor = octane.gui.create { type = octane.gui.componentType.TEXT_EDITOR, text = settings.fileTemplate, x = 20, width = settingsGrp.width - fileTemplateLbl.width - 40, height = 20, tooltip = "Template parameters: %f frame number %p render pass name %e extension", } fileTemplateEditor:bind("text", bindSettings, "fileTemplate") local fileTemplateRow = octane.gui.create { type = octane.gui.componentType.GROUP, border = false, rows = 1, cols = 2, children = { fileTemplateLbl, fileTemplateEditor }, } -- create a button to show a file chooser fileChooseButton = octane.gui.create { type = octane.gui.componentType.BUTTON, text = "Output Folder...", width = 120, height = 20, } -- create an editor that will show the chosen file path fileEditor = octane.gui.create { type = octane.gui.componentType.TEXT_EDITOR, text = settings.outputDirectory, x = 20, width = fileTemplateEditor.width, height = 20, enable = false, } fileEditor:bind("text", bindSettings, "outputDirectory") local row1 = octane.gui.create { type = octane.gui.componentType.GROUP, border = false, rows = 1, cols = 2, children = { fileChooseButton, fileEditor }, } startnrChk = octane.gui.create { type = octane.gui.componentType.CHECK_BOX, text = "Start file numbering at:", checked = settings.useFileNumber, width = 150, height = 20 } startnrChk:bind("checked", bindSettings, "useFileNumber") startnrNum = octane.gui.create { type = octane.gui.componentType.NUMERIC_BOX, minValue = 0, maxValue = 10000, value = settings.fileNumber, step = 1, x = 10, height = 20 } startnrNum:bind("value", bindSettings, "fileNumber") local row2 = octane.gui.create { type = octane.gui.componentType.GROUP, border = false, rows = 1, cols = 2, children = { startnrChk, startnrNum }, } skipExistingChk = octane.gui.create { type = octane.gui.componentType.CHECK_BOX, text = "Skip existing image files", checked = settings.skipExisting, width = 250, height = 20 } skipExistingChk:bind("checked", bindSettings, "skipExisting") saveRenderPassesChk = octane.gui.create { type = octane.gui.componentType.CHECK_BOX, text = "Save all enabled render passes", checked = settings.saveRenderPasses, width = 250, height = 20 } saveRenderPassesChk:bind("checked", bindSettings, "saveRenderPasses") multiLayerExrChk = octane.gui.create { x = 10, type = octane.gui.componentType.CHECK_BOX, text = "Use multi-layer EXR", checked = settings.useMultiLayerExr, enable = (settings.fileType == octane.render.imageType.EXR), width = 250, height = 20 } multiLayerExrChk:bind("checked", bindSettings, "useMultiLayerExr") 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.create { type = octane.gui.componentType.COMBO_BOX, 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 = { fileTemplateRow, row1, row2, 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 }, } -- progress bar -- eye candy, a progress bar local progressBar = octane.gui.create { type = octane.gui.componentType.PROGRESS_BAR, text = "render progress", width = fileGrp.width * 0.8, -- as wide as the group above height = 20, } -- for layouting the progress bar local progressGrp = octane.gui.create { type = octane.gui.componentType.GROUP, text = "", rows = 1, cols = 1, children = { progressBar }, padding = { 10 }, 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.create { type = octane.gui.componentType.BUTTON, text = RENDERTXT, width = 120, height = 20, } local exitButton = octane.gui.create { type = octane.gui.componentType.BUTTON, text = EXITTXT, width = 120, height = 20, } local buttonGrp = octane.gui.create { type = octane.gui.componentType.GROUP, text = "", rows = 1, cols = 2, children = { renderButton, exitButton }, padding = { 5 }, border = false, } -- group that layouts the other groups local layoutGrp = octane.gui.create { type = octane.gui.componentType.GROUP, text = "", rows = 4, cols = 1, 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 original scene graph and the rendertarget. -- This prevents us from modifying the original scene. local function getSceneCopy() -- get the selected render target local selectedRt = octane.project.getSelection()[1] if not selectedRt or selectedRt.type ~= octane.NT_RENDERTARGET then 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 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 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 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_START_SAMPLES, 1) 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 = "Frame "..currentFrame.." ("..seq.."/"..nbFrames..")" if nbFailed ~= 0 then text = text.." ("..nbFailed.." failed)" end progressBar.text = text -- create filename or render pass export objects local fileFrameNb = seq 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 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 = string.format("%s/%s", 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 = string.format("%s/%s", 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 = string.format("%s/%s", bindSettings.outputDirectory, exportObj.exportName) if not octane.file.exists(testFile) then skipFrame = false break 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 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 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, fileTemplateLbl, fileTemplateEditor, fileChooseButton, fileEditor, startnrChk, startnrNum, skipExistingChk, saveRenderPassesChk, fileTypeCombo, multiLayerExrChk } -- all the components on the last row are file input widgets and may be -- nil. In Lua this just gives a smaller array. -- 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