Skip to content

Instantly share code, notes, and snippets.

@CHFR-wide
Last active September 3, 2026 15:39
Show Gist options
  • Select an option

  • Save CHFR-wide/2c1fa3596b4bc2d02b5c2759bc1dab96 to your computer and use it in GitHub Desktop.

Select an option

Save CHFR-wide/2c1fa3596b4bc2d02b5c2759bc1dab96 to your computer and use it in GitHub Desktop.
Script for saving and applying easing presets within davinci fusion
-- Easecopy v1.0.3
-- Changelog:
-- v1.0.3 : Makes the script composition-agnostic
-- v1.0.2 : Fixes an issue where number-only ease names would crash the script
----------------------
-- UI_MANAGER SETUP --
----------------------
local ui = fu.UIManager
local disp = bmd.UIDispatcher(ui)
local width,height = 275,300
local positionX,positionY = 800,400
local currentComp = fu:GetCurrentComp()
win = disp:AddWindow({
ID = 'MyWin',
TargetID = 'MyWin',
WindowTitle = 'Ease Copy',
Geometry = {positionX, positionY, width, height},
Spacing = 0,
ui:VGroup{
ID = 'root',
ui:Label{
Weight = 0,
Text = 'Apply',
Alignment = {AlignHCenter = true},
},
ui:HGroup{
Weight = 0,
ui:Label { Weight = 0.5, Text = 'Ease', },
ui:ComboBox { Weight = 2, ID = 'qEase', Text = '', },
},
ui:HGroup{
Weight = 0,
ui:Label { Weight = 0.5, Text = 'Target', },
ui:ComboBox { Weight = 2, ID = 'qTargetProp', Text = '', },
},
ui:Button { Weight=0, ID = 'qApplyBtn', Text = 'Apply ease' },
ui:TabBar{},
ui:Label{
Weight = 0,
Text = 'Save',
Alignment = {AlignHCenter = true},
},
ui:HGroup{
Weight = 0,
ui:LineEdit { ID = 'qSaveEaseText' },
ui:Button { ID = 'qSaveBtn', Text = 'Save ease' },
},
ui:TabBar{},
ui:Label{
Weight = 0,
Text = 'Delete',
Alignment = {AlignHCenter = true},
},
ui:HGroup{
Weight = 0,
ui:Button {
Weight = 0.5,
ID = 'qDeleteOne',
Text = 'Selected ease'
},
ui:Button {
Weight = 0.5,
ID = 'qDeleteAll',
Text = 'All eases'
},
},
}
})
selectedTool = tool
itm = win:GetItems()
notify = ui:AddNotify('Comp_Activate_Tool')
-- EVENT BINDING --
function win.On.MyWin.Close(ev)
disp:ExitLoop()
end
function disp.On.Comp_Activate_Tool(ev)
ReloadTargetComboBox()
end
function win.On.qApplyBtn.Clicked(ev)
local presetName = itm.qEase.CurrentText
local targetProp = itm.qTargetProp.CurrentText
if (presetName ~= '') then
EaseCopy(presetName, targetProp)
end
end
function win.On.qSaveBtn.Clicked(ev)
local presetName = itm.qSaveEaseText.Text
local targetProp = itm.qTargetProp.CurrentText
if (presetName ~= '') then
if EaseCopy(presetName, targetProp, true) then
itm.qSaveEaseText.Text = ''
ReloadEaseComboBox(presetName)
end
end
end
function win.On.qDeleteOne.Clicked(ev)
local presetName = itm.qEase.CurrentText
fusion:SetData("easeCopy.presets." .. presetName, nil)
ReloadEaseComboBox()
print('Ease: ' .. presetName .. ' deleted.')
end
function win.On.qDeleteAll.Clicked(ev)
local confirmClear = currentComp:AskUser("Delete all eases?", {})
if not confirmClear then return end
fusion:SetData("easeCopy", nil)
ReloadEaseComboBox()
print('All eases have been deleted.')
end
-- DISPLAY UPDATE --
function ReloadEaseComboBox(newSelected)
dump('reloading')
local savedEases = fusion:GetData("easeCopy.presets")
local presets = {}
if savedEases then;
presets = GetKeys(fusion:GetData("easeCopy.presets"));
end
itm.qEase:Clear()
for _, preset in pairs(presets) do
itm.qEase:AddItem(preset)
end
if newSelected ~= '' then
itm.qEase:SetCurrentText(newSelected)
end
end
function ReloadTargetComboBox()
currentComp = fu:GetCurrentComp()
itm.qTargetProp:Clear()
itm.qTargetProp:AddItem('ALL')
for _, target in pairs(FindEligibleInputs(currentComp:GetToolList(true))) do
itm.qTargetProp:AddItem(target)
end
end
----------------
-- MAIN LOGIC --
----------------
function EaseCopy(presetName, targetProp, copy)
currentComp:StartUndo("EaseCopy")
currentComp:Lock()
for k,v in pairs(currentComp:GetToolList(true)) do
local endExecutionEarly = EaseCopyTool(v, presetName, targetProp, copy)
if (endExecutionEarly) then
currentComp:Unlock()
currentComp:EndUndo(true)
return true
end
end
currentComp:Unlock()
currentComp:EndUndo(true)
end
function EaseCopyTool(tool, presetName, targetProp, copy)
for k,v in pairs(tool:GetInputList()) do
local endExecutionEarly = EaseCopyInput(v, presetName, targetProp, copy)
if (endExecutionEarly) then
return true
end
end
end
function EaseCopyInput(input, presetName, targetProp, copy)
if not IsViableInput(input) then return end
local inputTool = GetTool(input)
if not inputTool then; return; end
if (IsModifier(inputTool) and not IsBezierSpline(inputTool)) then
return EaseCopyTool(inputTool, presetName, targetProp, copy)
end
if not IsTargetInput(input, targetProp) then return end
local keyframes = inputTool:GetKeyFrames()
if not keyframes then; return; end
local adjacentKeyframes = GetAdjacentKeyframes(keyframes)
if not adjacentKeyframes then
return
end
print("Found valid property: " .. input:GetAttrs().INPS_Name)
if copy then
CopyEase(presetName, adjacentKeyframes)
return true
else
local hardReplace = input:GetAttrs().INPS_ID ~= "Displacement"
PasteEase(inputTool, presetName, adjacentKeyframes, hardReplace)
end
end
function CopyEase(presetName, adjacentKeyframes)
local normalized = NormalizeKeyframePairHandles(adjacentKeyframes)
print("copying ease as " .. presetName)
fusion:SetData("easeCopy.presets." .. presetName, normalized)
end
function PasteEase(tool, presetName, adjacentKeyframes, hardReplace)
local ease = fusion:GetData("easeCopy.presets." .. presetName)
if ease then
print("pasting ease preset " .. presetName)
local denormalized = DenormalizeKeyframePairHandles(adjacentKeyframes, ease)
local oldKf = tool:GetKeyFrames()
local newKf = PatchExistingKeyFrames(oldKf, denormalized)
if hardReplace then
tool:DeleteKeyFrames(currentComp:GetAttrs().COMPN_GlobalStart, currentComp:GetAttrs().COMPN_GlobalEnd)
tool:SetKeyFrames(newKf, false)
else
ShowDisplacementWarning()
tool:SetKeyFrames(newKf, false)
-- This is not a mistake, for some reason, running this twice on Displacement properties
-- gives better (though still inconsistent) results
tool:SetKeyFrames(newKf, false)
end
end
end
function ShowDisplacementWarning()
if fusion:GetData('easeCopy.displacementWarning.doNotShow') == true then return end
local warnMessage = 'Pasting eases on a Displacement property can give inconsistent results, if it doesn\'t work as intended, please use an XY path modifier instead.'
local warnText = {"Message", Name="Message", "Text", ReadOnly = true, Wrap = true, Default = warnMessage, Lines = 5}
local checkBox = {"DoNotShowAgain", Name="Don\'t show this message again", "Checkbox"}
local dialog = currentComp:AskUser("Warning", {warnText, checkBox})
dump(dialog)
if dialog and dialog.DoNotShowAgain == 1 then
dump('Saving preferences')
fusion:SetData('easeCopy.displacementWarning.doNotShow', true)
end
end
----------------------------------
-- INPUT PARSING AND VALIDATION --
----------------------------------
function FindEligibleInputs(tools)
local eligibleInputs = {}
for _, tool in pairs(tools) do
for _, input in pairs(tool:GetInputList()) do
if IsViableInput(input) then
local inputTool = GetTool(input)
if IsModifier(inputTool) then
if IsBezierSpline(inputTool) then
local savedTarget = tool:GetAttrs().TOOLS_Name .. ":" .. input:GetAttrs().INPS_ID
table.insert(eligibleInputs, savedTarget )
else
for _, v in ipairs(FindEligibleInputs({inputTool})) do
table.insert(eligibleInputs, v)
end
end
end
end
end
end
return eligibleInputs
end
function IsViableInput(input)
return input:GetAttrs("INPB_Connected") and input:GetAttrs("INPS_DataType") ~= "LookUpTable"
end
function IsTargetInput(input, targetProp)
if targetProp == 'ALL' then return true end
t = Split(targetProp, ':')
dump(input:GetAttrs().INPS_ID)
dump(input:GetTool():GetAttrs().TOOLS_Name == t[1] and input:GetAttrs().INPS_ID == t[2])
return input:GetTool():GetAttrs().TOOLS_Name == t[1] and input:GetAttrs().INPS_ID == t[2]
end
-- thanks. https://www.steakunderwater.com/wesuckless/viewtopic.php?p=45445#p45445
function IsModifier(tool)
local regModifiers = fusion:GetRegList(fusion.CT_Modifier)
local toolAttrs = tool:GetAttrs()
for _,v in pairs(regModifiers) do
if v:GetAttrs().REGS_ID == toolAttrs.TOOLS_RegID then
return true
end
end
return false
end
function IsBezierSpline(tool)
return tool:GetAttrs().TOOLS_RegID == "BezierSpline"
end
function GetTool(input)
local output = input:GetConnectedOutput()
if (output ~= nil) then
return output:GetTool()
end
end
----------------------------
-- KEYFRAMES MANIPULATION --
----------------------------
function GetAdjacentKeyframes(keyframes)
local closestLeft = nil
local closestRight = nil
for k,v in pairs(keyframes) do
if k <= currentComp.CurrentTime and (closestLeft == nil or k > closestLeft) then
closestLeft = k
end
if k > currentComp.CurrentTime and (closestRight == nil or k < closestRight) then
closestRight = k
end
end
if (closestLeft and closestRight) then
return {[closestLeft] = keyframes[closestLeft], [closestRight] = keyframes[closestRight]}
end
end
function IsolateAdjacentKeyframes(keyframes, adjacent)
return {
[adjacent.Left] = keyframes[adjacent.Left],
[adjacent.Right] = keyframes[adjacent.Right]
}
end
function NormalizeKeyframePairHandles(adjacentKeyframes)
local tLeft, hLeft, tRight, hRight = SortAdjacentFrames(adjacentKeyframes)
local timeDiff = tRight - tLeft
local valueDiff = hRight[1] - hLeft[1]
if valueDiff == 0 then; return nil; end
local RH = hLeft.RH
local LH = hRight.LH
return {
RH = { RH[1] / timeDiff, RH[2] / valueDiff },
LH = { LH[1] / timeDiff, LH[2] / valueDiff },
}
end
function DenormalizeKeyframePairHandles(adjacentKeyframes, normalized)
local tLeft, hLeft, tRight, hRight = SortAdjacentFrames(adjacentKeyframes)
local timeDiff = tRight - tLeft
local valueDiff = hRight[1] - hLeft[1]
local RH = normalized.RH
local LH = normalized.LH
adjacentKeyframes[tLeft].RH = { RH[1] * timeDiff, RH[2] * valueDiff }
adjacentKeyframes[tRight].LH = { LH[1] * timeDiff, LH[2] * valueDiff }
adjacentKeyframes[tLeft].Flags = { RH[1] * timeDiff, RH[2] * valueDiff }
adjacentKeyframes[tRight].Flags = { LH[1] * timeDiff, LH[2] * valueDiff }
return adjacentKeyframes
end
function SortAdjacentFrames(adjacentKeyframes)
local tLeft, hLeft = next(adjacentKeyframes)
local tRight, hRight = next(adjacentKeyframes, tLeft)
if tLeft < tRight then
return tLeft, hLeft, tRight, hRight
else
return tRight, hRight, tLeft, hLeft
end
end
function PatchExistingKeyFrames(keyframes, denormalized)
local k1, v1 = next(denormalized)
local k2, v2 = next(denormalized, k1)
keyframes[k1] = v1
keyframes[k2] = v2
return keyframes
end
----------------------
-- HELPER FUNCTIONS --
----------------------
function PairsByKeys (t, f)
local a = {}
for n in pairs(t) do table.insert(a, tostring(n)) end
table.sort(a, f)
local i = 0
local iter = function ()
i = i + 1
if a[i] == nil then return nil
else return a[i], t[a[i]]
end
end
return iter
end
function GetKeys(t)
if t == nil then; return; end
local keys={}
for key,_ in PairsByKeys(t) do
table.insert(keys, key)
end
return keys
end
function Split (inputstr, sep)
if sep == nil then
sep = "%s"
end
local t={}
for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
table.insert(t, str)
end
return t
end
----------------------
ReloadEaseComboBox()
ReloadTargetComboBox()
win:Show()
disp:RunLoop()
win:Hide()
collectgarbage()
@infinitypacific

Copy link
Copy Markdown

Sadly it isn't working for me (i'm on 18.6.6) whenever I press save it just doesn't do anything...

@CHFR-wide

Copy link
Copy Markdown
Author

Hello @infinitypacific.
Does the console show any kind of error when you try to press save? Also could you attach an image of what your Resolve screen looks like when that happens?

@infinitypacific

Copy link
Copy Markdown

Sorry for the late response, it doesn't show anything really it just stays there.
image

@CHFR-wide

CHFR-wide commented Jul 16, 2024 •

Copy link
Copy Markdown
Author

Sorry for the late response, it doesn't show anything really it just stays there. image

Your playhead is at the wrong spot, the script looks for the keyframe directly on our before the playhead for the "left" keyframe, then it looks to any keyframe strictly after the playhead, if you move one frame before your ease should be saved normally

@ayushporwal01

Copy link
Copy Markdown

does it work in latest davinci 20

@ixed99

ixed99 commented Feb 3, 2026 •

Copy link
Copy Markdown

I'm on the free version of Davinci resolve 20, on MacOS, and after putting the script into the scripts folder, when ever i launch it it tells me about the studio version and tells me to pay the 250$, does this script only work on the studio version of Davinci?

@CHFR-wide

Copy link
Copy Markdown
Author

I'm on the free version of Davinci resolve 20, on MacOS, and after putting the script into the scripts folder, when ever i launch it it tells me about the studio version and tells me to pay the 250$, does this script only work on the studio version of Davinci?

For the free version of resolve, it will only work for versions strictly below 19.1. In 19.1, they made scripts that use the UI manager only work with the studio version.

@leoncrf

leoncrf commented Jun 11, 2026

Copy link
Copy Markdown

Hello, I can't save the animation curve. DaVinci Resolve Studio 21, W11.

Also, if a keyframe is created when there isn't one, it's necessary to reopen the tool to see it in the updated Target list. In other words, the Target list doesn't seem to update automatically, requiring reopening.

Also, congratulations on creating the script; I used it a few times and it was very useful, although I can't use it now.

image

@CHFR-wide

Copy link
Copy Markdown
Author

Hello, I can't save the animation curve. DaVinci Resolve Studio 21, W11.

Also, if a keyframe is created when there isn't one, it's necessary to reopen the tool to see it in the updated Target list. In other words, the Target list doesn't seem to update automatically, requiring reopening.

Also, congratulations on creating the script; I used it a few times and it was very useful, although I can't use it now.
image

Hi, Two things look a bit off in this image:

  • Is your cursor between the two keyframes? I don't see it in the spline panel. The script can only look at keyframes the cursor is contained in.
  • It looks like the first and second keyframes have exactly the same value, when the ease is being saved, there's a normalization step that needs to be done, this step divides by the value difference, so if the two keyframes have the exact same value it will cause a division by zero (no matter the easing).
    • (yapping a bit:) The script could potentially make it work by skipping normalization when there's no value difference, but without normalization, you'd be looking at the eases' raw values, and for example, your curve that jumps from 0.5 to 0.8 (so a 0.3 difference) would be tiny on a parameter like rotation, where one spin is 360 whole degrees.

@leoncrf

leoncrf commented Jun 11, 2026

Copy link
Copy Markdown

1- I tested with the playhead between the two keyframes, before, after, and on top of both keyframes.

2- I also tested with keyframes that have different values.

Positioning the playhead between the keyframes and using different values, the console output is this:
image

@CHFR-wide

Copy link
Copy Markdown
Author

The error messages seem unrelated to the script itself, also, this is not quite between, the cursor can be on top of the first keyframe, but it can't be on top of the last, It's an [in, out[ range for implementation reasons.

@leoncrf

leoncrf commented Jun 11, 2026 •

Copy link
Copy Markdown

As mensagens de erro parecem não ter relação com o script em si. Além disso, isso não é exatamente o meio termo. O cursor pode estar em cima do primeiro quadro-chave, mas não pode estar em cima do último. É um intervalo [dentro, fora[ por motivos de implementação.

The error in the console is generated after clicking the save ease button.

I also tried positioning the playhead above the first keyframe, but it doesn't work.

@CHFR-wide

Copy link
Copy Markdown
Author

That's a bit of a problem, I am still on 18.6.6 so I am not be able to test on versions past 19.1, one more thing that you could try, is to use a version of the script that was modified by someone called paperclipfx, they shared it in a public discord server so I'll relay the script with attribution.

It seems to have a few more improvements like easier access to presets and stuff. I don't know if this version will work for you, but I think it has better chances than my version, which I haven't touched in at least a year.

Once again, all credits for this modified version goes to them. I'm just pasting it here as they don't seem to have publicized it anywhere.

-- Easecopy v1.0.9 - Adjusted Header and Button Layout

-- Changelog:
-- v1.0.9 : Moved Apply header to top, restored Apply button to original position
-- v1.0.8 : Made cycle buttons compact, increased dropdown space, moved Apply button to top
-- v1.0.7 : Moved cycle buttons beside Ease and Target dropdowns, adjusted cycling functionality
-- v1.0.6 : Added cycle buttons for navigation without applying, moved headers to top
-- v1.0.5 : Fixed target dropdown issues with enhanced debugging and manual refresh
-- v1.0.4 : Added numbered buttons for quick preset access + rename functionality
-- v1.0.3 : Makes the script composition-agnostic
-- v1.0.2 : Fixes an issue where number-only ease names would crash the script
-- Modified: Moved Apply header to top, restored Apply button

----------------------
-- UI_MANAGER SETUP --
----------------------

local ui = fu.UIManager
local disp = bmd.UIDispatcher(ui)
local width,height = 275,400
local positionX,positionY = 800,400
local currentComp = fu:GetCurrentComp()

win = disp:AddWindow({
	ID = 'MyWin',
	TargetID = 'MyWin',
	WindowTitle = 'Ease Copy',
	Geometry = {positionX, positionY, width, height},
	Spacing = 0,
	ui:VGroup{
		ID = 'root',
		ui:Label{ Weight = 0, Text = 'Apply', Alignment = {AlignHCenter = true}, },
		ui:VGroup{
			Weight = 0,
			ui:HGroup{
				Weight = 0,
				ui:Label{ Weight = 0.5, Text = 'Ease', Alignment = {AlignHCenter = true}, },
				ui:ComboBox{ Weight = 2, ID = 'qEase', Text = '', },
				ui:Button{ Weight = 0.3, ID = 'qCyclePrevEase', Text = '<<', MinimumSize = {20, 20}, },
				ui:Button{ Weight = 0.3, ID = 'qCycleNextEase', Text = '>>', MinimumSize = {20, 20}, },
			},
			ui:HGroup{
				Weight = 0,
				ui:Label{ Weight = 0.5, Text = 'Target', Alignment = {AlignHCenter = true}, },
				ui:ComboBox{ Weight = 2, ID = 'qTargetProp', Text = '', },
				ui:Button{ Weight = 0.3, ID = 'qCyclePrevTarget', Text = '<<', MinimumSize = {20, 20}, },
				ui:Button{ Weight = 0.3, ID = 'qCycleNextTarget', Text = '>>', MinimumSize = {20, 20}, },
			},
			ui:Button{ Weight = 0, ID = 'qApplyBtn', Text = 'Apply ease' },
			ui:Button{ Weight = 0, ID = 'qRefreshTargets', Text = 'Refresh Targets' },
			ui:TabBar{},
		},
		ui:VGroup{
			Weight = 0,
			ui:Label{ Weight = 0, Text = 'Save', Alignment = {AlignHCenter = true}, },
			ui:HGroup{
				Weight = 0,
				ui:ComboBox{ Weight = 0.3, ID = 'qSaveMode', Text = 'save', },
				ui:LineEdit{ Weight = 1.7, ID = 'qSaveEaseText' },
				ui:Button{ Weight = 1, ID = 'qSaveBtn', Text = 'Save ease' },
			},
			ui:HGroup{
				Weight = 0,
				ui:Button{ Weight = 1, ID = 'qApplyPrevious', Text = 'Apply previous' },
				ui:Button{ Weight = 1, ID = 'qApplyNext', Text = 'Apply next' },
			},
		},
		ui:VGroup{
			Weight = 1,
			ID = 'ButtonsArea',
			-- Row 1
			ui:HGroup{
				Weight = 0,
				ID = 'ButtonRow1',
				ui:Button{ ID = 'qBtn1', Text = '1', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn2', Text = '2', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn3', Text = '3', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn4', Text = '4', Weight = 1, MinimumSize = {40, 30}, Visible = false },
			},
			-- Row 2
			ui:HGroup{
				Weight = 0,
				ID = 'ButtonRow2',
				ui:Button{ ID = 'qBtn5', Text = '5', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn6', Text = '6', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn7', Text = '7', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn8', Text = '8', Weight = 1, MinimumSize = {40, 30}, Visible = false },
			},
			-- Row 3
			ui:HGroup{
				Weight = 0,
				ID = 'ButtonRow3',
				ui:Button{ ID = 'qBtn9', Text = '9', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn10', Text = '10', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn11', Text = '11', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn12', Text = '12', Weight = 1, MinimumSize = {40, 30}, Visible = false },
			},
			-- Row 4
			ui:HGroup{
				Weight = 0,
				ID = 'ButtonRow4',
				ui:Button{ ID = 'qBtn13', Text = '13', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn14', Text = '14', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn15', Text = '15', Weight = 1, MinimumSize = {40, 30}, Visible = false },
				ui:Button{ ID = 'qBtn16', Text = '16', Weight = 1, MinimumSize = {40, 30}, Visible = false },
			},
		},
		ui:TabBar{},
		ui:VGroup{
			Weight = 0,
			ui:HGroup{
				Weight = 0,
				ui:ComboBox{ Weight = 0.3, ID = 'qDeleteProtection', Text = 'off', },
				ui:Button{ Weight = 0.7, ID = 'qDeleteOne', Text = 'Delete selected ease' },
			},
		},
	}
})

selectedTool = tool
itm = win:GetItems()
notify = ui:AddNotify('Comp_Activate_Tool')

-- Store preset to button mapping
presetButtons = {}
maxButtons = 16  -- Maximum number of buttons we have

-- Initialize the dropdowns
itm.qDeleteProtection:AddItem('off')
itm.qDeleteProtection:AddItem('on')
itm.qDeleteProtection:SetCurrentText('off')

-- Initialize save mode dropdown
itm.qSaveMode:AddItem('save')
itm.qSaveMode:AddItem('rename')
itm.qSaveMode:SetCurrentText('save')

-- EVENT BINDING --

function win.On.MyWin.Close(ev)
	disp:ExitLoop()
end

function disp.On.Comp_Activate_Tool(ev)
	ReloadTargetComboBox()
end

function win.On.qRefreshTargets.Clicked(ev)
	ReloadTargetComboBox()
	print("Target dropdown refreshed")
end

function win.On.qApplyBtn.Clicked(ev)
	local presetName = itm.qEase.CurrentText
	local targetProp = itm.qTargetProp.CurrentText

	if (presetName ~= '') then
		EaseCopy(presetName, targetProp)
	end
end

function win.On.qSaveBtn.Clicked(ev)
	local saveMode = itm.qSaveMode.CurrentText
	local presetName = itm.qSaveEaseText.Text
	local targetProp = itm.qTargetProp.CurrentText

	if (presetName ~= '') then
		if saveMode == 'save' then
			if EaseCopy(presetName, targetProp, true) then
				itm.qSaveEaseText.Text = ''
				ReloadEaseComboBox(presetName)
				UpdateNumberedButtons()
			end
		elseif saveMode == 'rename' then
			HandleRename(presetName)
		end
	end
end

function HandleRename(newPresetName)
	local oldPresetName = itm.qEase.CurrentText
	if not oldPresetName or oldPresetName == '' then
		print('No ease selected to rename.')
		return
	end
	
	if oldPresetName == newPresetName then
		print('New name is the same as current name.')
		return
	end
	
	local savedEases = fusion:GetData("easeCopy.presets")
	if savedEases and savedEases[newPresetName] then
		local overwriteConfirm = currentComp:AskUser("Preset Already Exists", {
			{"Message", Name="Message", "Text", ReadOnly = true, 
			 Default = "A preset with the name '" .. newPresetName .. "' already exists. Do you want to overwrite it?", 
			 Lines = 2}
		})
		if not overwriteConfirm then
			return
		end
	end
	
	local oldPresetData = fusion:GetData("easeCopy.presets." .. oldPresetName)
	if not oldPresetData then
		print('Selected ease preset not found.')
		return
	end
	
	fusion:SetData("easeCopy.presets." .. newPresetName, oldPresetData)
	fusion:SetData("easeCopy.presets." .. oldPresetName, nil)
	
	currentComp:AskUser("Rename Successful", {
		{"Message", Name="Message", "Text", ReadOnly = true, 
		 Default = "Ease preset renamed from '" .. oldPresetName .. "' to '" .. newPresetName .. "'", 
		 Lines = 2}
	})
	
	itm.qSaveEaseText.Text = ''
	itm.qSaveMode:SetCurrentText('save')
	ReloadEaseComboBox(newPresetName)
	UpdateNumberedButtons()
	print('Ease preset renamed from "' .. oldPresetName .. '" to "' .. newPresetName .. '"')
end

function win.On.qSaveMode.CurrentIndexChanged(ev)
	local saveMode = itm.qSaveMode.CurrentText
	
	if saveMode == 'save' then
		itm.qSaveBtn.Text = 'Save ease'
		itm.qSaveEaseText.Text = ''
	elseif saveMode == 'rename' then
		itm.qSaveBtn.Text = 'Rename ease'
		local currentEase = itm.qEase.CurrentText
		if currentEase and currentEase ~= '' then
			itm.qSaveEaseText.Text = currentEase
		else
			itm.qSaveEaseText.Text = ''
		end
	end
end

function win.On.qEase.CurrentIndexChanged(ev)
	local saveMode = itm.qSaveMode.CurrentText
	
	if saveMode == 'rename' then
		local currentEase = itm.qEase.CurrentText
		if currentEase and currentEase ~= '' then
			itm.qSaveEaseText.Text = currentEase
		end
	end
end

function win.On.qApplyPrevious.Clicked(ev)
	local savedEases = fusion:GetData("easeCopy.presets")
	if not savedEases then
		print('No ease presets available.')
		return
	end
	
	local presets = GetKeys(savedEases)
	local presetCount = #presets
	
	if presetCount == 0 then
		print('No ease presets available.')
		return
	end
	
	local currentPreset = itm.qEase.CurrentText
	local currentIndex = 1
	
	for i, preset in ipairs(presets) do
		if preset == currentPreset then
			currentIndex = i
			break
		end
	end
	
	local previousIndex = currentIndex - 1
	if previousIndex < 1 then
		previousIndex = presetCount
	end
	
	local newPreset = presets[previousIndex]
	itm.qEase:SetCurrentText(newPreset)
	
	local targetProp = itm.qTargetProp.CurrentText
	EaseCopy(newPreset, targetProp)
	print('Applied previous ease: ' .. newPreset)
end

function win.On.qApplyNext.Clicked(ev)
	local savedEases = fusion:GetData("easeCopy.presets")
	if not savedEases then
		print('No ease presets available.')
		return
	end
	
	local presets = GetKeys(savedEases)
	local presetCount = #presets
	
	if presetCount == 0 then
		print('No ease presets available.')
		return
	end
	
	local currentPreset = itm.qEase.CurrentText
	local currentIndex = 1
	
	for i, preset in ipairs(presets) do
		if preset == currentPreset then
			currentIndex = i
			break
		end
	end
	
	local nextIndex = currentIndex + 1
	if nextIndex > presetCount then
		nextIndex = 1
	end
	
	local newPreset = presets[nextIndex]
	itm.qEase:SetCurrentText(newPreset)
	
	local targetProp = itm.qTargetProp.CurrentText
	EaseCopy(newPreset, targetProp)
	print('Applied next ease: ' .. newPreset)
end

function win.On.qDeleteOne.Clicked(ev)
	local deleteProtection = itm.qDeleteProtection.CurrentText
	
	if deleteProtection == 'off' then
		print('Delete function is disabled. Set dropdown to "on" to enable deletion.')
		return
	end
	
	local presetName = itm.qEase.CurrentText
	
	if (presetName == '') then
		print('No ease selected to delete.')
		itm.qDeleteProtection:SetCurrentText('off')
		return
	end
	
	local confirmDelete = currentComp:AskUser("Delete selected ease?", {
		{"Message", Name="Message", "Text", ReadOnly = true, Default = "Are you sure you want to delete the ease '" .. presetName .. "'?", Lines = 2}
	})
	
	if confirmDelete then
		fusion:SetData("easeCopy.presets." .. presetName, nil)
		ReloadEaseComboBox()
		UpdateNumberedButtons()
		print('Ease: ' .. presetName .. ' deleted.')
	end
	
	itm.qDeleteProtection:SetCurrentText('off')
end

function win.On.qCyclePrevEase.Clicked(ev)
	local savedEases = fusion:GetData("easeCopy.presets")
	if not savedEases then
		print('No ease presets available.')
		return
	end
	
	local presets = GetKeys(savedEases)
	local presetCount = #presets
	
	if presetCount == 0 then
		print('No ease presets available.')
		return
	end
	
	local currentPreset = itm.qEase.CurrentText
	local currentIndex = 1
	
	for i, preset in ipairs(presets) do
		if preset == currentPreset then
			currentIndex = i
			break
		end
	end
	
	local previousIndex = currentIndex - 1
	if previousIndex < 1 then
		previousIndex = presetCount
	end
	
	local newPreset = presets[previousIndex]
	itm.qEase:SetCurrentText(newPreset)
	print('Cycled to previous ease: ' .. newPreset)
end

function win.On.qCycleNextEase.Clicked(ev)
	local savedEases = fusion:GetData("easeCopy.presets")
	if not savedEases then
		print('No ease presets available.')
		return
	end
	
	local presets = GetKeys(savedEases)
	local presetCount = #presets
	
	if presetCount == 0 then
		print('No ease presets available.')
		return
	end
	
	local currentPreset = itm.qEase.CurrentText
	local currentIndex = 1
	
	for i, preset in ipairs(presets) do
		if preset == currentPreset then
			currentIndex = i
			break
		end
	end
	
	local nextIndex = currentIndex + 1
	if nextIndex > presetCount then
		nextIndex = 1
	end
	
	local newPreset = presets[nextIndex]
	itm.qEase:SetCurrentText(newPreset)
	print('Cycled to next ease: ' .. newPreset)
end

function win.On.qCyclePrevTarget.Clicked(ev)
	local targets = GetTargetList()
	local targetCount = #targets
	
	if targetCount == 0 then
		print('No targets available.')
		return
	end
	
	local currentTarget = itm.qTargetProp.CurrentText
	local currentIndex = 1
	
	for i, target in ipairs(targets) do
		if target == currentTarget then
			currentIndex = i
			break
		end
	end
	
	local previousIndex = currentIndex - 1
	if previousIndex < 1 then
		previousIndex = targetCount
	end
	
	local newTarget = targets[previousIndex]
	itm.qTargetProp:SetCurrentText(newTarget)
	print('Cycled to previous target: ' .. newTarget)
end

function win.On.qCycleNextTarget.Clicked(ev)
	local targets = GetTargetList()
	local targetCount = #targets
	
	if targetCount == 0 then
		print('No targets available.')
		return
	end
	
	local currentTarget = itm.qTargetProp.CurrentText
	local currentIndex = 1
	
	for i, target in ipairs(targets) do
		if target == currentTarget then
			currentIndex = i
			break
		end
	end
	
	local nextIndex = currentIndex + 1
	if nextIndex > targetCount then
		nextIndex = 1
	end
	
	local newTarget = targets[nextIndex]
	itm.qTargetProp:SetCurrentText(newTarget)
	print('Cycled to next target: ' .. newTarget)
end

function GetTargetList()
	local selectedTools = currentComp:GetToolList(true)
	local targets = {'ALL'}
	
	if not selectedTools or #selectedTools == 0 then
		return targets
	end
	
	local eligibleInputs = FindEligibleInputs(selectedTools)
	for _, target in pairs(eligibleInputs) do
		table.insert(targets, target)
	end
	
	return targets
end

function UpdateNumberedButtons()
	for i = 1, maxButtons do
		local buttonId = 'qBtn' .. i
		if itm[buttonId] then
			itm[buttonId].Visible = false
		end
	end
	
	presetButtons = {}
	
	local savedEases = fusion:GetData("easeCopy.presets")
	if not savedEases then
		return
	end
	
	local presets = GetKeys(savedEases)
	if not presets or #presets == 0 then
		return
	end
	
	for i, presetName in ipairs(presets) do
		if i <= maxButtons then
			local buttonId = 'qBtn' .. i
			if itm[buttonId] then
				itm[buttonId].Visible = true
				presetButtons[i] = presetName
			end
		end
	end
	
	print('Updated numbered buttons for ' .. #presets .. ' presets')
end

function win.On.qBtn1.Clicked(ev) ApplyPresetByNumber(1) end
function win.On.qBtn2.Clicked(ev) ApplyPresetByNumber(2) end
function win.On.qBtn3.Clicked(ev) ApplyPresetByNumber(3) end
function win.On.qBtn4.Clicked(ev) ApplyPresetByNumber(4) end
function win.On.qBtn5.Clicked(ev) ApplyPresetByNumber(5) end
function win.On.qBtn6.Clicked(ev) ApplyPresetByNumber(6) end
function win.On.qBtn7.Clicked(ev) ApplyPresetByNumber(7) end
function win.On.qBtn8.Clicked(ev) ApplyPresetByNumber(8) end
function win.On.qBtn9.Clicked(ev) ApplyPresetByNumber(9) end
function win.On.qBtn10.Clicked(ev) ApplyPresetByNumber(10) end
function win.On.qBtn11.Clicked(ev) ApplyPresetByNumber(11) end
function win.On.qBtn12.Clicked(ev) ApplyPresetByNumber(12) end
function win.On.qBtn13.Clicked(ev) ApplyPresetByNumber(13) end
function win.On.qBtn14.Clicked(ev) ApplyPresetByNumber(14) end
function win.On.qBtn15.Clicked(ev) ApplyPresetByNumber(15) end
function win.On.qBtn16.Clicked(ev) ApplyPresetByNumber(16) end

function ApplyPresetByNumber(buttonNumber)
	local presetName = presetButtons[buttonNumber]
	if presetName then
		local targetProp = itm.qTargetProp.CurrentText
		itm.qEase:SetCurrentText(presetName)
		EaseCopy(presetName, targetProp)
		print('Applied preset "' .. presetName .. '" via button ' .. buttonNumber)
	end
end

function ReloadEaseComboBox(newSelected)
	dump('reloading')
	local savedEases = fusion:GetData("easeCopy.presets")
	local presets = {}

	if savedEases then
		presets = GetKeys(fusion:GetData("easeCopy.presets"))
	end

	itm.qEase:Clear()
	for _, preset in pairs(presets) do
		itm.qEase:AddItem(preset)
	end

	if newSelected and newSelected ~= '' then
		itm.qEase:SetCurrentText(newSelected)
	end
end

function ReloadTargetComboBox()
	currentComp = fu:GetCurrentComp()
	local selectedTools = currentComp:GetToolList(true)
	
	local currentSelection = itm.qTargetProp.CurrentText
	
	itm.qTargetProp:Clear()
	itm.qTargetProp:AddItem('ALL')
	
	if not selectedTools or #selectedTools == 0 then
		print("No tools selected. Please select a tool in the composition.")
		itm.qTargetProp:SetCurrentText('ALL')
		return
	end
	
	local eligibleInputs = FindEligibleInputs(selectedTools)
	local selectionStillValid = false
	
	for _, target in pairs(eligibleInputs) do
		itm.qTargetProp:AddItem(target)
		if target == currentSelection then
			selectionStillValid = true
		end
	end
	
	if selectionStillValid and currentSelection ~= '' then
		itm.qTargetProp:SetCurrentText(currentSelection)
	else
		itm.qTargetProp:SetCurrentText('ALL')
	end
	
	print("Target dropdown populated with " .. itm.qTargetProp:Count() .. " items")
end

function FindEligibleInputs(tools)
	local eligibleInputs = {}
	for _, tool in pairs(tools) do
		print("Processing tool: " .. tool:GetAttrs().TOOLS_Name)
		for _, input in pairs(tool:GetInputList()) do
			if IsViableInput(input) then
				print("  Viable input: " .. input:GetAttrs().INPS_Name)
				local inputTool = GetTool(input)
				if inputTool then
					if IsModifier(inputTool) then
						if IsBezierSpline(inputTool) then
							local savedTarget = tool:GetAttrs().TOOLS_Name .. ":" .. input:GetAttrs().INPS_ID
							print("    Adding target: " .. savedTarget)
							table.insert(eligibleInputs, savedTarget)
						else
							print("    Recursing into modifier: " .. inputTool:GetAttrs().TOOLS_Name)
							for _, v in ipairs(FindEligibleInputs({inputTool})) do
								table.insert(eligibleInputs, v)
							end
						end
					end
				else
					print("  No tool found for input: " .. input:GetAttrs().INPS_Name)
				end
			else
				print("  Input not viable: " .. input:GetAttrs().INPS_Name)
			end
		end
	end
	return eligibleInputs
end

----------------
-- MAIN LOGIC --
----------------

function EaseCopy(presetName, targetProp, copy)
	currentComp:StartUndo("EaseCopy")
	currentComp:Lock()
	for k,v in pairs(currentComp:GetToolList(true)) do
		local endExecutionEarly = EaseCopyTool(v, presetName, targetProp, copy)
		if (endExecutionEarly) then
			currentComp:Unlock()
			currentComp:EndUndo(true)
			return true
		end
	end
	currentComp:Unlock()
	currentComp:EndUndo(true)
end

function EaseCopyTool(tool, presetName, targetProp, copy)
	for k,v in pairs(tool:GetInputList()) do
		local endExecutionEarly = EaseCopyInput(v, presetName, targetProp, copy)
		if (endExecutionEarly) then
			return true
		end
	end
end

function EaseCopyInput(input, presetName, targetProp, copy)
	if not IsViableInput(input) then return end
	
	local inputTool = GetTool(input)
	if not inputTool then return end

	if (IsModifier(inputTool) and not IsBezierSpline(inputTool)) then
		return EaseCopyTool(inputTool, presetName, targetProp, copy)
	end

	if not IsTargetInput(input, targetProp) then return end
	
	local keyframes = inputTool:GetKeyFrames()
	if not keyframes then return end

	local adjacentKeyframes = GetAdjacentKeyframes(keyframes)
	if not adjacentKeyframes then return end

	print("Found valid property: " .. input:GetAttrs().INPS_Name)
	if copy then
		CopyEase(presetName, adjacentKeyframes)
		return true
	else
		local hardReplace = input:GetAttrs().INPS_ID ~= "Displacement"
		PasteEase(inputTool, presetName, adjacentKeyframes, hardReplace)
	end
end

function CopyEase(presetName, adjacentKeyframes)
	local normalized = NormalizeKeyframePairHandles(adjacentKeyframes)
	print("copying ease as " .. presetName)
	fusion:SetData("easeCopy.presets." .. presetName, normalized)
end

function PasteEase(tool, presetName, adjacentKeyframes, hardReplace)
	local ease = fusion:GetData("easeCopy.presets." .. presetName)
	if ease then
		print("pasting ease preset " .. presetName)
		local denormalized = DenormalizeKeyframePairHandles(adjacentKeyframes, ease)
		local oldKf = tool:GetKeyFrames()
		local newKf = PatchExistingKeyFrames(oldKf, denormalized)

		if hardReplace then
			tool:DeleteKeyFrames(currentComp:GetAttrs().COMPN_GlobalStart, currentComp:GetAttrs().COMPN_GlobalEnd)
			tool:SetKeyFrames(newKf, false)
		else
			ShowDisplacementWarning()
			tool:SetKeyFrames(newKf, false)
			tool:SetKeyFrames(newKf, false)
		end
	end
end

function ShowDisplacementWarning()
	if fusion:GetData('easeCopy.displacementWarning.doNotShow') == true then return end
	local warnMessage = 'Pasting eases on a Displacement property can give inconsistent results, if it doesn\'t work as intended, please use an XY path modifier instead.'
	local warnText = {"Message", Name="Message", "Text", ReadOnly = true, Wrap = true, Default = warnMessage, Lines = 5}
	local checkBox = {"DoNotShowAgain", Name="Don\'t show this message again", "Checkbox"}
	local dialog = currentComp:AskUser("Warning", {warnText, checkBox})
	dump(dialog)
	if dialog and dialog.DoNotShowAgain == 1 then
		dump('Saving preferences')
		fusion:SetData('easeCopy.displacementWarning.doNotShow', true)
	end
end

----------------------------------
-- INPUT PARSING AND VALIDATION --
----------------------------------

function IsViableInput(input)
	return input:GetAttrs("INPB_Connected") and input:GetAttrs("INPS_DataType") ~= "LookUpTable"
end

function IsTargetInput(input, targetProp)
	if targetProp == 'ALL' then return true end
	local t = Split(targetProp, ':')
	return input:GetTool():GetAttrs().TOOLS_Name == t[1] and input:GetAttrs().INPS_ID == t[2]
end

function IsModifier(tool)
	local regModifiers = fusion:GetRegList(fusion.CT_Modifier)
	local toolAttrs = tool:GetAttrs()
	for _, v in pairs(regModifiers) do
		if v:GetAttrs().REGS_ID == toolAttrs.TOOLS_RegID then
			print("Found modifier: " .. toolAttrs.TOOLS_Name)
			return true
		end
	end
	print("Not a modifier: " .. toolAttrs.TOOLS_Name)
	return false
end

function IsBezierSpline(tool)
	local isBezier = tool:GetAttrs().TOOLS_RegID == "BezierSpline"
	print("Tool " .. tool:GetAttrs().TOOLS_Name .. " is BezierSpline: " .. tostring(isBezier))
	return isBezier
end

function GetTool(input)
	local output = input:GetConnectedOutput()
	if output then
		return output:GetTool()
	end
end

----------------------------
-- KEYFRAMES MANIPULATION --
----------------------------

function GetAdjacentKeyframes(keyframes)
	local closestLeft = nil
	local closestRight = nil
	for k,v in pairs(keyframes) do
		if k <= currentComp.CurrentTime and (closestLeft == nil or k > closestLeft) then
			closestLeft = k
		end
		if k > currentComp.CurrentTime and (closestRight == nil or k < closestRight) then
			closestRight = k
		end
	end
	if closestLeft and closestRight then
		return {[closestLeft] = keyframes[closestLeft], [closestRight] = keyframes[closestRight]}
	end
end

function NormalizeKeyframePairHandles(adjacentKeyframes)
	local tLeft, hLeft, tRight, hRight = SortAdjacentFrames(adjacentKeyframes)
	local timeDiff = tRight - tLeft
	local valueDiff = hRight[1] - hLeft[1]
	if valueDiff == 0 then return nil end
	local RH = hLeft.RH
	local LH = hRight.LH
	return {
		RH = { RH[1] / timeDiff, RH[2] / valueDiff },
		LH = { LH[1] / timeDiff, LH[2] / valueDiff },
	}
end

function DenormalizeKeyframePairHandles(adjacentKeyframes, normalized)
	local tLeft, hLeft, tRight, hRight = SortAdjacentFrames(adjacentKeyframes)
	local timeDiff = tRight - tLeft
	local valueDiff = hRight[1] - hLeft[1]
	local RH = normalized.RH
	local LH = normalized.LH
	adjacentKeyframes[tLeft].RH = { RH[1] * timeDiff, RH[2] * valueDiff }
	adjacentKeyframes[tRight].LH = { LH[1] * timeDiff, LH[2] * valueDiff }
	adjacentKeyframes[tLeft].Flags = { RH[1] * timeDiff, RH[2] * valueDiff }
	adjacentKeyframes[tRight].Flags = { LH[1] * timeDiff, LH[2] * valueDiff }
	return adjacentKeyframes
end

function SortAdjacentFrames(adjacentKeyframes)
	local tLeft, hLeft = next(adjacentKeyframes)
	local tRight, hRight = next(adjacentKeyframes, tLeft)
	if tLeft < tRight then
		return tLeft, hLeft, tRight, hRight
	else
		return tRight, hRight, tLeft, hLeft
	end
end

function PatchExistingKeyFrames(keyframes, denormalized)
	local k1, v1 = next(denormalized)
	local k2, v2 = next(denormalized, k1)
	keyframes[k1] = v1
	keyframes[k2] = v2
	return keyframes
end

----------------------
-- HELPER FUNCTIONS --
----------------------

function PairsByKeys(t, f)
	local a = {}
	for n in pairs(t) do table.insert(a, tostring(n)) end
	table.sort(a, f)
	local i = 0
	local iter = function()
		i = i + 1
		if a[i] == nil then return nil
		else return a[i], t[a[i]]
		end
	end
	return iter
end

function GetKeys(t)
	if t == nil then return {} end 
	local keys={}
	for key,_ in PairsByKeys(t) do
		table.insert(keys, key)
	end
	return keys
end

function Split(inputstr, sep)
	if sep == nil then
		sep = "%s"
	end
	local t={}
	for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
		table.insert(t, str)
	end
	return t
end

----------------------

ReloadEaseComboBox()
ReloadTargetComboBox()
UpdateNumberedButtons()
win:Show()
disp:RunLoop()
win:Hide()

collectgarbage()

@leoncrf

leoncrf commented Jun 11, 2026

Copy link
Copy Markdown

Thanks, it's working!
Excuse me for asking, which Discord server did you mention?

Why are you still using Resolve version 18, besides the fact that some plugins aren't compatible with recent versions?

@CHFR-wide

Copy link
Copy Markdown
Author

Thanks, it's working! Excuse me for asking, which Discord server did you mention?

Why are you still using Resolve version 18, besides the fact that some plugins aren't compatible with recent versions?

  1. PeejENT's discord: https://discord.com/invite/RkEEEnXQnZ, they have a davinci discorvery channel where people post stuff, and that's where someone posted a reworked version of my script.
  2. I use 18.6.6 because 19.1 made script UIs studio-only, I don't use any 19.X version before 19.1 because early major davinci version are often said to be unstable, so I use the last 18.X version instead. As of now, I feel like blackmagic has yet to give fusion a feature good enough to make losing script UIs worth it. And while I could easily buy studio, I'm still hesitating because I might want to introduce other people to davinci, and it would have to be the free version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment