davinci-resolve-mcp 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +85 -0
  2. package/CHANGELOG.md +802 -0
  3. package/CLAUDE.md +15 -0
  4. package/LICENSE +21 -0
  5. package/README.md +159 -0
  6. package/SECURITY.md +53 -0
  7. package/bin/davinci-resolve-mcp.mjs +376 -0
  8. package/docs/README.md +56 -0
  9. package/docs/SKILL.md +1145 -0
  10. package/docs/authoring/fuse-dctl-authoring.md +242 -0
  11. package/docs/authoring/script-plugin-authoring.md +195 -0
  12. package/docs/contributing.md +82 -0
  13. package/docs/guides/color-decision-guide.md +387 -0
  14. package/docs/guides/editorial-decision-guide.md +136 -0
  15. package/docs/guides/media-analysis-guide.md +615 -0
  16. package/docs/guides/multicam-setup-guide.md +138 -0
  17. package/docs/install.md +198 -0
  18. package/docs/integrations/workflow-integrations.md +120 -0
  19. package/docs/kernels/README.md +28 -0
  20. package/docs/kernels/audio-fairlight-kernel.md +86 -0
  21. package/docs/kernels/color-grade-kernel.md +103 -0
  22. package/docs/kernels/extension-authoring-kernel.md +101 -0
  23. package/docs/kernels/fusion-composition-kernel.md +91 -0
  24. package/docs/kernels/media-pool-ingest-kernel.md +147 -0
  25. package/docs/kernels/project-lifecycle-kernel.md +120 -0
  26. package/docs/kernels/render-deliver-kernel.md +92 -0
  27. package/docs/kernels/review-annotation-kernel.md +110 -0
  28. package/docs/kernels/timeline-conform-interchange-kernel.md +99 -0
  29. package/docs/kernels/timeline-edit-kernel.md +189 -0
  30. package/docs/notes/codec-plugin-notes.md +136 -0
  31. package/docs/notes/dctl-notes.md +234 -0
  32. package/docs/notes/fusion-template-notes.md +136 -0
  33. package/docs/notes/lut-notes.md +136 -0
  34. package/docs/notes/openfx-notes.md +120 -0
  35. package/docs/process/release-process.md +152 -0
  36. package/docs/reference/api-coverage.md +488 -0
  37. package/docs/reference/resolve_scripting_api.txt +1012 -0
  38. package/examples/README.md +53 -0
  39. package/examples/markers/README.md +81 -0
  40. package/examples/media/README.md +94 -0
  41. package/examples/timeline/README.md +98 -0
  42. package/install.py +1196 -0
  43. package/package.json +52 -0
  44. package/scripts/audit_api_parity.py +275 -0
  45. package/scripts/live_media_analysis_polish_probe.py +65 -0
  46. package/src/__init__.py +3 -0
  47. package/src/analysis_dashboard.py +4936 -0
  48. package/src/control_panel.py +13 -0
  49. package/src/granular/__init__.py +17 -0
  50. package/src/granular/common.py +727 -0
  51. package/src/granular/folder.py +287 -0
  52. package/src/granular/gallery.py +306 -0
  53. package/src/granular/graph.py +309 -0
  54. package/src/granular/media_pool.py +679 -0
  55. package/src/granular/media_pool_item.py +852 -0
  56. package/src/granular/media_storage.py +179 -0
  57. package/src/granular/project.py +1594 -0
  58. package/src/granular/resolve_control.py +521 -0
  59. package/src/granular/timeline.py +1074 -0
  60. package/src/granular/timeline_item.py +2251 -0
  61. package/src/resolve_mcp_server.py +43 -0
  62. package/src/server.py +15691 -0
  63. package/src/utils/__init__.py +3 -0
  64. package/src/utils/app_control.py +319 -0
  65. package/src/utils/audio_fairlight_live_probe.py +263 -0
  66. package/src/utils/cdl.py +20 -0
  67. package/src/utils/cloud_operations.py +192 -0
  68. package/src/utils/color_grade_live_probe.py +444 -0
  69. package/src/utils/dctl_templates.py +368 -0
  70. package/src/utils/extension_authoring_live_probe.py +292 -0
  71. package/src/utils/fuse_templates.py +1968 -0
  72. package/src/utils/fusion_composition_live_probe.py +284 -0
  73. package/src/utils/layout_presets.py +333 -0
  74. package/src/utils/mcp_stdio.py +32 -0
  75. package/src/utils/media_analysis.py +3618 -0
  76. package/src/utils/media_analysis_jobs.py +796 -0
  77. package/src/utils/media_pool_ingest_live_probe.py +592 -0
  78. package/src/utils/multicam.py +393 -0
  79. package/src/utils/object_inspection.py +287 -0
  80. package/src/utils/platform.py +157 -0
  81. package/src/utils/project_lifecycle_live_probe.py +376 -0
  82. package/src/utils/project_properties.py +601 -0
  83. package/src/utils/render_deliver_live_probe.py +384 -0
  84. package/src/utils/resolve_connection.py +77 -0
  85. package/src/utils/review_annotation_live_probe.py +352 -0
  86. package/src/utils/script_templates.py +1193 -0
  87. package/src/utils/sync_detection.py +887 -0
  88. package/src/utils/timeline_conform_live_probe.py +280 -0
  89. package/src/utils/timeline_kernel_live_probe.py +1091 -0
  90. package/src/utils/timeline_kernel_probe.py +185 -0
  91. package/src/utils/timeline_title_text.py +87 -0
  92. package/src/utils/update_check.py +610 -0
@@ -0,0 +1,1968 @@
1
+ #!/usr/bin/env python3
2
+ """Boilerplate generators for Fusion Fuse plugins.
3
+
4
+ Each generator returns a complete, syntactically-valid .fuse source string ready
5
+ to be written to the Fuses install directory. Generated sources include a
6
+ machine-readable header comment so list() can identify MCP-installed Fuses.
7
+
8
+ Reference: Fusion Fuse SDK (June 2023), Blackmagic Design.
9
+ """
10
+
11
+ from typing import Any, Dict, Optional
12
+
13
+ # Maps the public `type` argument to Fusion's internal ClassType enum.
14
+ CLASS_TYPES = {
15
+ "tool": "CT_Tool",
16
+ "modifier": "CT_Modifier",
17
+ "viewlut": "CT_ViewLUTPlugin",
18
+ }
19
+
20
+ # Linear color ops the color_matrix template knows how to compose.
21
+ COLOR_MATRIX_OPS = ("brightness", "contrast", "gain", "saturation", "invert")
22
+
23
+
24
+ def header(name: str, kind: str, fuse_type: str) -> str:
25
+ """MCP marker comment placed at the top of generated Fuses."""
26
+ return (
27
+ f"-- @mcp-fuse name={name} kind={kind} type={fuse_type}\n"
28
+ "-- Generated by davinci-resolve-mcp. Edit freely; the marker above\n"
29
+ "-- only affects how the MCP `fuse_plugin list` action reports this file.\n\n"
30
+ )
31
+
32
+
33
+ def color_matrix(name: str, options: Optional[Dict[str, Any]] = None) -> str:
34
+ """Linear color tool composed from a sequence of matrix operations.
35
+
36
+ options:
37
+ ops: list of operation names from COLOR_MATRIX_OPS.
38
+ Defaults to ['brightness', 'contrast', 'saturation'].
39
+
40
+ The generated Fuse exposes one slider per op and applies them as a single
41
+ ColorMatrixFull, which is faster than chaining separate image operations.
42
+ See Fuse SDK pp. 24 and 102–105.
43
+ """
44
+ options = options or {}
45
+ ops = options.get("ops") or ["brightness", "contrast", "saturation"]
46
+ invalid = [o for o in ops if o not in COLOR_MATRIX_OPS]
47
+ if invalid:
48
+ raise ValueError(f"Unknown color_matrix ops: {invalid}. "
49
+ f"Valid: {list(COLOR_MATRIX_OPS)}")
50
+
51
+ inputs = []
52
+ matrix_steps = []
53
+
54
+ for op in ops:
55
+ if op == "brightness":
56
+ inputs.append(_slider("InBright", "Brightness", default=0.0,
57
+ min_=-1.0, max_=1.0))
58
+ matrix_steps.append(
59
+ " local b = InBright:GetValue(req).Value\n"
60
+ " m:Offset(b, b, b, 0)"
61
+ )
62
+ elif op == "contrast":
63
+ inputs.append(_slider("InContrast", "Contrast", default=1.0,
64
+ min_=0.0, max_=2.0))
65
+ matrix_steps.append(
66
+ " local c = InContrast:GetValue(req).Value\n"
67
+ " m:Offset(-0.5, -0.5, -0.5, 0)\n"
68
+ " m:Scale(c, c, c, 1)\n"
69
+ " m:Offset(0.5, 0.5, 0.5, 0)"
70
+ )
71
+ elif op == "gain":
72
+ inputs.append(_slider("InGain", "Gain", default=1.0,
73
+ min_=0.0, max_=4.0))
74
+ matrix_steps.append(
75
+ " local g = InGain:GetValue(req).Value\n"
76
+ " m:Scale(g, g, g, 1)"
77
+ )
78
+ elif op == "saturation":
79
+ inputs.append(_slider("InSat", "Saturation", default=1.0,
80
+ min_=0.0, max_=4.0))
81
+ matrix_steps.append(
82
+ " local s = InSat:GetValue(req).Value\n"
83
+ " m:RGBtoYUV()\n"
84
+ " m:Scale(1, s, s, 1)\n"
85
+ " m:YUVtoRGB()"
86
+ )
87
+ elif op == "invert":
88
+ inputs.append(_checkbox("InInvert", "Invert"))
89
+ matrix_steps.append(
90
+ " if InInvert:GetValue(req).Value > 0.5 then\n"
91
+ " m:Scale(-1, -1, -1, 1)\n"
92
+ " m:Offset(1, 1, 1, 0)\n"
93
+ " end"
94
+ )
95
+
96
+ inputs_block = "\n".join(inputs)
97
+ process_block = "\n".join(matrix_steps)
98
+ op_list = ", ".join(ops)
99
+
100
+ return header(name, "color_matrix", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
101
+ REGS_Category = "Fuses\\\\MCP",
102
+ REGS_OpIconString = "{name[:4]}",
103
+ REGS_OpDescription = "Color matrix tool ({op_list})",
104
+ REG_NoBlendCtrls = false,
105
+ REG_NoMotionBlurCtrls = true,
106
+ }})
107
+
108
+ function Create()
109
+ {inputs_block}
110
+
111
+ InImage = self:AddInput("Input", "Input", {{
112
+ LINKID_DataType = "Image",
113
+ LINK_Main = 1,
114
+ }})
115
+
116
+ OutImage = self:AddOutput("Output", "Output", {{
117
+ LINKID_DataType = "Image",
118
+ LINK_Main = 1,
119
+ }})
120
+ end
121
+
122
+ function Process(req)
123
+ local img = InImage:GetValue(req)
124
+ local m = ColorMatrixFull()
125
+
126
+ {process_block}
127
+
128
+ local out = img:ApplyMatrixOf(m, {{}})
129
+ OutImage:Set(req, out)
130
+ end
131
+ '''
132
+
133
+
134
+ def per_pixel(name: str, options: Optional[Dict[str, Any]] = None) -> str:
135
+ """Per-pixel processing tool using MultiProcessPixels (multithreaded).
136
+
137
+ options:
138
+ inputs: 1 or 2 image inputs (default 1)
139
+ expression: Lua body executed per pixel. Receives (x, y, p1) or
140
+ (x, y, p1, p2). Must mutate and return p1.
141
+ Default: copies p1 unchanged.
142
+ amount: bool, whether to expose an Amount slider as `amt` in the
143
+ expression (default True)
144
+
145
+ See Fuse SDK pp. 29-30, 88-89.
146
+ """
147
+ options = options or {}
148
+ n_inputs = int(options.get("inputs", 1))
149
+ if n_inputs not in (1, 2):
150
+ raise ValueError("per_pixel: inputs must be 1 or 2")
151
+ expression = options.get("expression", " return p1")
152
+ expose_amount = bool(options.get("amount", True))
153
+
154
+ pixel_args = "x, y, p1, p2" if n_inputs == 2 else "x, y, p1"
155
+
156
+ extra_input = ""
157
+ fetch_extra = ""
158
+ pass_extra = "img1, img1" # MultiProcessPixels needs 2 images even with 1 input
159
+ if n_inputs == 2:
160
+ extra_input = '''
161
+ InImage2 = self:AddInput("Input 2", "Input2", {
162
+ LINKID_DataType = "Image",
163
+ LINK_Main = 2,
164
+ INP_Required = false,
165
+ })
166
+ '''
167
+ fetch_extra = " local img2 = InImage2:GetValue(req)\n if img2 == nil then img2 = img1 end"
168
+ pass_extra = "img1, img2"
169
+
170
+ amount_input = ""
171
+ amount_pass = ""
172
+ if expose_amount:
173
+ amount_input = _slider("InAmount", "Amount", default=1.0, min_=0.0, max_=2.0)
174
+ amount_pass = "{ amt = InAmount:GetValue(req).Value }"
175
+ else:
176
+ amount_pass = "{}"
177
+
178
+ return header(name, "per_pixel", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
179
+ REGS_Category = "Fuses\\\\MCP",
180
+ REGS_OpIconString = "{name[:4]}",
181
+ REGS_OpDescription = "Per-pixel custom op",
182
+ }})
183
+
184
+ -- Pixel function: must be defined before Process() (no header files in Lua).
185
+ -- p1 and p2 are Pixel structs with members R, G, B, A and aux channels.
186
+ local function pixel_op({pixel_args})
187
+ {expression}
188
+ end
189
+
190
+ function Create()
191
+ {amount_input}{extra_input}
192
+ InImage = self:AddInput("Input", "Input", {{
193
+ LINKID_DataType = "Image",
194
+ LINK_Main = 1,
195
+ }})
196
+
197
+ OutImage = self:AddOutput("Output", "Output", {{
198
+ LINKID_DataType = "Image",
199
+ LINK_Main = 1,
200
+ }})
201
+ end
202
+
203
+ function Process(req)
204
+ local img1 = InImage:GetValue(req)
205
+ {fetch_extra}
206
+
207
+ local out = Image({{ IMG_Like = img1 }})
208
+ out:MultiProcessPixels(nil, {amount_pass},
209
+ 0, 0, img1.Width, img1.Height,
210
+ {pass_extra}, pixel_op)
211
+ OutImage:Set(req, out)
212
+ end
213
+ '''
214
+
215
+
216
+ def view_lut(name: str, options: Optional[Dict[str, Any]] = None) -> str:
217
+ """View LUT plugin: GLSL shader applied during display only.
218
+
219
+ options:
220
+ shader: GLSL body for `void ShadePixel(inout FuPixel f)`.
221
+ Default: a simple gamma adjustment driven by InGamma.
222
+ params: list of {name, type='float', default=1.0, min=0.0, max=5.0}
223
+ Each becomes a slider in the Inspector AND a uniform in the
224
+ shader. Default: one InGamma slider.
225
+
226
+ FuPixel members available in the shader:
227
+ Color vec4 — source pixel color
228
+ TexCoord0 vec4 — pixel coordinates [0..w-1, 0..h-1]
229
+ TexCoord1 vec4 — normalized coordinates [0..1, 0..1]
230
+ TexCoord2 vec4 — destination screen coordinates
231
+
232
+ See Fuse SDK pp. 118–124.
233
+ """
234
+ options = options or {}
235
+ params = options.get("params") or [
236
+ {"name": "Gamma", "type": "float", "default": 1.0, "min": 0.0, "max": 5.0}
237
+ ]
238
+ shader_body = options.get("shader") or (
239
+ " EvalShadePixel(f);\n"
240
+ " f.Color.rgb = sign(f.Color.rgb) * pow(abs(f.Color.rgb), vec3(Gamma));"
241
+ )
242
+
243
+ valid_types = ("float", "vec2", "vec3_rgb", "vec4_rgba")
244
+ inputs = []
245
+ setparams = []
246
+ shader_params = []
247
+ for i, prm in enumerate(params, start=1):
248
+ pname = prm["name"]
249
+ ptype = prm.get("type", "float")
250
+ if ptype not in valid_types:
251
+ raise ValueError(f"Invalid view_lut param type '{ptype}'. "
252
+ f"Valid: {list(valid_types)}")
253
+ if ptype == "float":
254
+ default = prm.get("default", 1.0)
255
+ pmin = prm.get("min", 0.0)
256
+ pmax = prm.get("max", 5.0)
257
+ inputs.append(_slider(f"In{pname}", pname, default=default,
258
+ min_=pmin, max_=pmax))
259
+ setparams.append(
260
+ f" vsnode:Set({i}, In{pname}:GetValue(req).Value)"
261
+ )
262
+ shader_params.append(f"float {pname}")
263
+ elif ptype == "vec2":
264
+ inputs.append(
265
+ f' In{pname} = self:AddInput("{pname}", "{pname}", {{\n'
266
+ f' LINKID_DataType = "Point",\n'
267
+ f' INPID_InputControl = "OffsetControl",\n'
268
+ f' INPID_PreviewControl = "CrosshairControl",\n'
269
+ f' INP_DefaultX = 0.5, INP_DefaultY = 0.5,\n'
270
+ f' }})\n'
271
+ )
272
+ setparams.append(
273
+ f" local p_{pname} = In{pname}:GetValue(req)\n"
274
+ f" vsnode:Set({i}, p_{pname}.X, p_{pname}.Y)"
275
+ )
276
+ shader_params.append(f"vec2 {pname}")
277
+ elif ptype in ("vec3_rgb", "vec4_rgba"):
278
+ count = 3 if ptype == "vec3_rgb" else 4
279
+ channel_names = ["Red", "Green", "Blue", "Alpha"][:count]
280
+ for j, chan in enumerate(channel_names):
281
+ inputs.append(
282
+ f' In{pname}{chan} = self:AddInput('
283
+ f'"{pname} {chan}", "{pname}{chan}", {{\n'
284
+ f' LINKID_DataType = "Number",\n'
285
+ f' INPID_InputControl = "ColorControl",\n'
286
+ f' IC_ControlGroup = {100 + i},\n'
287
+ f' IC_ControlID = {j},\n'
288
+ f' INP_Default = 1.0,\n'
289
+ f' }})\n'
290
+ )
291
+ getters = ", ".join(
292
+ f"In{pname}{chan}:GetValue(req).Value"
293
+ for chan in channel_names
294
+ )
295
+ setparams.append(f" vsnode:Set({i}, {getters})")
296
+ glsl_type = "vec3" if ptype == "vec3_rgb" else "vec4"
297
+ shader_params.append(f"{glsl_type} {pname}")
298
+
299
+ inputs_block = "\n".join(inputs)
300
+ setparams_block = "\n".join(setparams)
301
+ params_string = "\\n".join(shader_params)
302
+
303
+ return header(name, "view_lut", "viewlut") + f'''FuRegisterClass("{name}", CT_ViewLUTPlugin, {{
304
+ REGS_Name = "{name}",
305
+ REGS_Category = "ViewShaders",
306
+ }})
307
+
308
+ function Create()
309
+ {inputs_block}
310
+ end
311
+
312
+ local params = [[
313
+ {chr(10).join(shader_params)}
314
+ ]]
315
+
316
+ local shader = [[
317
+ void ShadePixel(inout FuPixel f)
318
+ {{
319
+ {shader_body}
320
+ }}
321
+ ]]
322
+
323
+ function SetupShadeNode(group, req, img)
324
+ return ViewShadeNode(group, "{name}", params, shader)
325
+ end
326
+
327
+ function SetupParams(req, vsnode, img)
328
+ {setparams_block}
329
+ return true
330
+ end
331
+ '''
332
+
333
+
334
+ def transform(name: str, options: Optional[Dict[str, Any]] = None) -> str:
335
+ """Geometric warp tool with on-screen Center crosshair, Angle, and Size.
336
+
337
+ options:
338
+ edge_mode: 'Black' | 'Canvas' | 'Wrap' | 'Duplicate' (default 'Black')
339
+
340
+ The generated Fuse uses Image:Transform under the hood, with the OffsetControl
341
+ Y-coordinate squashed conversion baked in so the on-screen crosshair lands
342
+ where the user clicks regardless of image aspect ratio. See Fuse SDK p. 115.
343
+ """
344
+ options = options or {}
345
+ edge_mode = options.get("edge_mode", "Black")
346
+ if edge_mode not in ("Black", "Canvas", "Wrap", "Duplicate"):
347
+ raise ValueError(f"Invalid edge_mode '{edge_mode}'. "
348
+ "Valid: Black, Canvas, Wrap, Duplicate")
349
+
350
+ return header(name, "transform", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
351
+ REGS_Category = "Fuses\\\\MCP",
352
+ REGS_OpIconString = "{name[:4]}",
353
+ REGS_OpDescription = "Transform tool (warp/rotate/scale)",
354
+ }})
355
+
356
+ function Create()
357
+ InCenter = self:AddInput("Center", "Center", {{
358
+ LINKID_DataType = "Point",
359
+ INPID_InputControl = "OffsetControl",
360
+ INPID_PreviewControl = "CrosshairControl",
361
+ INP_DefaultX = 0.5,
362
+ INP_DefaultY = 0.5,
363
+ }})
364
+
365
+ InAngle = self:AddInput("Angle", "Angle", {{
366
+ LINKID_DataType = "Number",
367
+ INPID_InputControl = "ScrewControl",
368
+ INP_Default = 0.0,
369
+ INP_MinScale = -180.0,
370
+ INP_MaxScale = 180.0,
371
+ }})
372
+
373
+ InSize = self:AddInput("Size", "Size", {{
374
+ LINKID_DataType = "Number",
375
+ INPID_InputControl = "SliderControl",
376
+ INP_Default = 1.0,
377
+ INP_MinScale = 0.0,
378
+ INP_MaxScale = 4.0,
379
+ }})
380
+
381
+ InImage = self:AddInput("Input", "Input", {{
382
+ LINKID_DataType = "Image",
383
+ LINK_Main = 1,
384
+ }})
385
+
386
+ OutImage = self:AddOutput("Output", "Output", {{
387
+ LINKID_DataType = "Image",
388
+ LINK_Main = 1,
389
+ }})
390
+ end
391
+
392
+ function Process(req)
393
+ local img = InImage:GetValue(req)
394
+ local center = InCenter:GetValue(req)
395
+ local angle = InAngle:GetValue(req).Value
396
+ local size = InSize:GetValue(req).Value
397
+
398
+ local out = img:Transform(nil, {{
399
+ XF_XOffset = center.X,
400
+ XF_YOffset = center.Y,
401
+ XF_XAxis = 0.5,
402
+ XF_YAxis = 0.5,
403
+ XF_XSize = size,
404
+ XF_YSize = size,
405
+ XF_Angle = angle,
406
+ XF_EdgeMode = "{edge_mode}",
407
+ }})
408
+
409
+ OutImage:Set(req, out)
410
+ end
411
+ '''
412
+
413
+
414
+ def text_overlay(name: str, options: Optional[Dict[str, Any]] = None) -> str:
415
+ """Renders styled text into the input image using Fusion's font shape system.
416
+
417
+ options:
418
+ default_text: str (default '')
419
+ justify: 'left' | 'center' | 'right' (default 'center')
420
+
421
+ The generated Fuse exposes Styled Text, Font, Style, Size, Position, Color,
422
+ and a justify dropdown. Internally uses Fontmetrics + ImageChannel + Shape +
423
+ PutToImage with CM_Merge so text overlays the input image.
424
+ See Fuse SDK pp. 33-36 (Example6_Text reference implementation).
425
+ """
426
+ options = options or {}
427
+ default_text = options.get("default_text", "")
428
+ justify_label = options.get("justify", "center")
429
+ justify_index = {"left": 0, "center": 1, "right": 2}.get(justify_label)
430
+ if justify_index is None:
431
+ raise ValueError(f"Invalid justify '{justify_label}'. "
432
+ "Valid: left, center, right")
433
+
434
+ return header(name, "text_overlay", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
435
+ REGS_Category = "Fuses\\\\MCP",
436
+ REGS_OpIconString = "{name[:4]}",
437
+ REGS_OpDescription = "Text overlay",
438
+ }})
439
+
440
+ function Create()
441
+ InText = self:AddInput("Styled Text", "StyledText", {{
442
+ LINKID_DataType = "Text",
443
+ INPID_InputControl = "TextEditControl",
444
+ TEC_Lines = 3,
445
+ INPS_DefaultText = "{default_text}",
446
+ }})
447
+
448
+ self:BeginControlNest("Font", "FontNest", true, {{}})
449
+ InFont = self:AddInput("Font", "Font", {{
450
+ LINKID_DataType = "Text",
451
+ INPID_InputControl = "FontFileControl",
452
+ IC_ControlGroup = 1,
453
+ IC_ControlID = 0,
454
+ INP_DoNotifyChanged = true,
455
+ }})
456
+
457
+ InStyle = self:AddInput("Style", "Style", {{
458
+ LINKID_DataType = "Text",
459
+ INPID_InputControl = "FontFileControl",
460
+ IC_ControlGroup = 1,
461
+ IC_ControlID = 1,
462
+ INP_DoNotifyChanged = true,
463
+ }})
464
+
465
+ InSize = self:AddInput("Size", "Size", {{
466
+ LINKID_DataType = "Number",
467
+ INPID_InputControl = "SliderControl",
468
+ INP_Default = 0.08,
469
+ INP_MinScale = 0.0,
470
+ INP_MaxScale = 1.0,
471
+ }})
472
+ self:EndControlNest()
473
+
474
+ self:BeginControlNest("Layout", "LayoutNest", true, {{}})
475
+ InPosition = self:AddInput("Position", "Position", {{
476
+ LINKID_DataType = "Point",
477
+ INPID_InputControl = "OffsetControl",
478
+ INPID_PreviewControl = "CrosshairControl",
479
+ INP_DefaultX = 0.5,
480
+ INP_DefaultY = 0.5,
481
+ }})
482
+
483
+ InJustify = self:AddInput("Justify", "Justify", {{
484
+ LINKID_DataType = "Number",
485
+ INPID_InputControl = "ComboControl",
486
+ INP_Default = {justify_index},
487
+ INP_Integer = true,
488
+ {{ CCS_AddString = "Left", }},
489
+ {{ CCS_AddString = "Center", }},
490
+ {{ CCS_AddString = "Right", }},
491
+ }})
492
+ self:EndControlNest()
493
+
494
+ self:BeginControlNest("Color", "ColorNest", false, {{}})
495
+ InR = self:AddInput("Red", "Red", {{
496
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
497
+ IC_ControlGroup = 2, IC_ControlID = 0, INP_Default = 1.0,
498
+ }})
499
+ InG = self:AddInput("Green", "Green", {{
500
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
501
+ IC_ControlGroup = 2, IC_ControlID = 1, INP_Default = 1.0,
502
+ }})
503
+ InB = self:AddInput("Blue", "Blue", {{
504
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
505
+ IC_ControlGroup = 2, IC_ControlID = 2, INP_Default = 1.0,
506
+ }})
507
+ InA = self:AddInput("Alpha", "Alpha", {{
508
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
509
+ IC_ControlGroup = 2, IC_ControlID = 3, INP_Default = 1.0,
510
+ }})
511
+ self:EndControlNest()
512
+
513
+ InImage = self:AddInput("Input", "Input", {{
514
+ LINKID_DataType = "Image",
515
+ LINK_Main = 1,
516
+ }})
517
+
518
+ OutImage = self:AddOutput("Output", "Output", {{
519
+ LINKID_DataType = "Image",
520
+ LINK_Main = 1,
521
+ }})
522
+ end
523
+
524
+ -- drawstring renders one or more lines of text into ImageChannel `ic`.
525
+ -- Adapted from the SDK's Example6_Text Fuse.
526
+ local function drawstring(img, font_name, style, size, justify, quality,
527
+ x, y, colour, text)
528
+ local ic = ImageChannel(img, quality)
529
+ local fs = FillStyle()
530
+ local cs = ChannelStyle()
531
+ cs.Color = colour
532
+ ic:SetStyleFill(fs)
533
+
534
+ local font = TextStyleFont(font_name, style)
535
+ local tfm = TextStyleFontMetrics(font)
536
+ local line_height = (tfm.TextAscent + tfm.TextDescent +
537
+ tfm.TextExternalLeading) * 10 * size
538
+
539
+ local mat = Matrix4()
540
+ mat:Scale(1.0 / tfm.Scale, 1.0 / tfm.Scale, 1.0)
541
+ mat:Scale(size, size, 1)
542
+ mat:Move(x, y, 0)
543
+
544
+ local shape = Shape()
545
+
546
+ for line in string.gmatch(text, "%C+") do
547
+ local line_width = 0
548
+ for i = 1, #line do
549
+ local ch = line:sub(i, i):byte()
550
+ line_width = line_width + tfm:CharacterWidth(ch) * 10 * size
551
+ end
552
+
553
+ if justify == 1 then
554
+ mat:Move(-line_width / 2, 0, 0)
555
+ elseif justify == 2 then
556
+ mat:Move(-line_width, 0, 0)
557
+ end
558
+
559
+ local prevch
560
+ local x_move = 0
561
+ for i = 1, #line do
562
+ local ch = line:sub(i, i):byte()
563
+ local cw = tfm:CharacterWidth(ch) * 10 * size
564
+
565
+ if prevch then
566
+ local x_offset = tfm:CharacterKerning(prevch, ch) * 10 * size
567
+ x_move = x_move + x_offset
568
+ mat:Move(x_offset, 0, 0)
569
+ end
570
+
571
+ mat:Move(cw / 2, 0, 0)
572
+ local sh = tfm:GetCharacterShape(ch, false)
573
+ sh = sh:TransformOfShape(mat)
574
+ mat:Move(cw / 2, 0, 0)
575
+ x_move = x_move + cw
576
+
577
+ shape:AddShape(sh)
578
+ prevch = ch
579
+ end
580
+
581
+ if justify == 0 then
582
+ mat:Move(-x_move, -line_height, 0)
583
+ elseif justify == 1 then
584
+ mat:Move(-x_move / 2, -line_height, 0)
585
+ else
586
+ mat:Move(0, -line_height, 0)
587
+ end
588
+ end
589
+
590
+ ic:ShapeFill(shape)
591
+ ic:PutToImage("CM_Merge", cs)
592
+ end
593
+
594
+ function Process(req)
595
+ local img = InImage:GetValue(req)
596
+ local out = img:CopyOf()
597
+
598
+ local text = InText:GetValue(req).Value or ""
599
+ if text == "" then
600
+ OutImage:Set(req, out)
601
+ return
602
+ end
603
+
604
+ local font = InFont:GetValue(req).Value or "Open Sans"
605
+ local style = InStyle:GetValue(req).Value or "Regular"
606
+ local size = InSize:GetValue(req).Value
607
+ local pos = InPosition:GetValue(req)
608
+ local justify = InJustify:GetValue(req).Value
609
+ local r = InR:GetValue(req).Value
610
+ local g = InG:GetValue(req).Value
611
+ local b = InB:GetValue(req).Value
612
+ local a = InA:GetValue(req).Value
613
+
614
+ -- Squash-correction so the on-screen point lands where the user clicks.
615
+ local cx = pos.X
616
+ local cy = pos.Y * (out.Height * out.YScale) / (out.Width * out.XScale)
617
+
618
+ if FontManager and not next(FontManager:GetFontList()) then
619
+ FontManager:ScanDir()
620
+ end
621
+
622
+ local quality = 32
623
+ if req:IsQuick() then quality = 1 end
624
+
625
+ drawstring(out, font, style, size, justify, quality, cx, cy,
626
+ Pixel({{R = r, G = g, B = b, A = a}}), text)
627
+
628
+ OutImage:Set(req, out)
629
+ end
630
+ '''
631
+
632
+
633
+ def modifier(name: str, options: Optional[Dict[str, Any]] = None) -> str:
634
+ """Modifier plugin: drives a Number input on another tool with a function of time.
635
+
636
+ options:
637
+ kind: 'sine' | 'noise' | 'ramp' (default 'sine')
638
+
639
+ Modifiers have no image inputs; they expose a Number output. To use the
640
+ generated modifier, right-click any slider in the Inspector → Modify with →
641
+ [name]. See Fuse SDK p. 9 (Modifier description) and p. 40 (CT_Modifier).
642
+ """
643
+ options = options or {}
644
+ kind = options.get("kind", "sine")
645
+ if kind not in ("sine", "noise", "ramp"):
646
+ raise ValueError(f"Invalid modifier kind '{kind}'. "
647
+ "Valid: sine, noise, ramp")
648
+
649
+ if kind == "sine":
650
+ formula = "math.sin(2 * math.pi * (t * freq + phase)) * amp + offset"
651
+ elif kind == "ramp":
652
+ formula = "((t * freq + phase) % 1.0) * amp + offset"
653
+ else: # noise
654
+ formula = ("amp * (math.random() - 0.5) * 2 + offset")
655
+
656
+ return header(name, "modifier", "modifier") + f'''FuRegisterClass("{name}", CT_Modifier, {{
657
+ REGS_Category = "Fuses\\\\MCP",
658
+ REGS_OpIconString = "{name[:4]}",
659
+ REGS_OpDescription = "Number modifier ({kind})",
660
+ }})
661
+
662
+ function Create()
663
+ InFreq = self:AddInput("Frequency", "Frequency", {{
664
+ LINKID_DataType = "Number",
665
+ INPID_InputControl = "SliderControl",
666
+ INP_Default = 1.0,
667
+ INP_MinScale = 0.0,
668
+ INP_MaxScale = 10.0,
669
+ }})
670
+
671
+ InAmp = self:AddInput("Amplitude", "Amplitude", {{
672
+ LINKID_DataType = "Number",
673
+ INPID_InputControl = "SliderControl",
674
+ INP_Default = 1.0,
675
+ INP_MinScale = 0.0,
676
+ INP_MaxScale = 10.0,
677
+ }})
678
+
679
+ InPhase = self:AddInput("Phase", "Phase", {{
680
+ LINKID_DataType = "Number",
681
+ INPID_InputControl = "SliderControl",
682
+ INP_Default = 0.0,
683
+ INP_MinScale = -1.0,
684
+ INP_MaxScale = 1.0,
685
+ }})
686
+
687
+ InOffset = self:AddInput("Offset", "Offset", {{
688
+ LINKID_DataType = "Number",
689
+ INPID_InputControl = "SliderControl",
690
+ INP_Default = 0.0,
691
+ INP_MinScale = -10.0,
692
+ INP_MaxScale = 10.0,
693
+ }})
694
+
695
+ OutValue = self:AddOutput("Value", "Value", {{
696
+ LINKID_DataType = "Number",
697
+ LINK_Main = 1,
698
+ }})
699
+ end
700
+
701
+ function Process(req)
702
+ local t = req.Time
703
+ local freq = InFreq:GetValue(req).Value
704
+ local amp = InAmp:GetValue(req).Value
705
+ local phase = InPhase:GetValue(req).Value
706
+ local offset = InOffset:GetValue(req).Value
707
+
708
+ local v = {formula}
709
+ OutValue:Set(req, Number(v))
710
+ end
711
+ '''
712
+
713
+
714
+ def dctl_kernel(name: str, options: Optional[Dict[str, Any]] = None) -> str:
715
+ """Fuse that wraps a DCTL kernel via DVIPComputeNode (GPU compute).
716
+
717
+ options:
718
+ kernel: GPU kernel body. Default is a per-pixel gain shader.
719
+ params: list of {name, type='float', default=1.0, min=0.0, max=4.0}
720
+ Each becomes a Lua slider AND a kernel parameter.
721
+
722
+ The generated Fuse runs on Metal (macOS), CUDA (NVIDIA), or OpenCL (AMD)
723
+ via Resolve's built-in DCTL compiler. See Fuse SDK pp. 130-143 (Circle/
724
+ Gradient example).
725
+ """
726
+ options = options or {}
727
+ params = options.get("params") or [
728
+ {"name": "Gain", "type": "float", "default": 1.0, "min": 0.0, "max": 4.0}
729
+ ]
730
+ kernel = options.get("kernel") or (
731
+ " float4 c = _tex2DVecN(src, x, y, 15);\n"
732
+ " c.x *= params->Gain;\n"
733
+ " c.y *= params->Gain;\n"
734
+ " c.z *= params->Gain;\n"
735
+ " _tex2DVec4Write(dst, x, y, c);"
736
+ )
737
+
738
+ inputs = []
739
+ set_lines = []
740
+ param_struct = []
741
+ for prm in params:
742
+ pname = prm["name"]
743
+ ptype = prm.get("type", "float")
744
+ default = prm.get("default", 1.0)
745
+ pmin = prm.get("min", 0.0)
746
+ pmax = prm.get("max", 4.0)
747
+ inputs.append(_slider(f"In{pname}", pname, default=default,
748
+ min_=pmin, max_=pmax))
749
+ set_lines.append(f" paramblock.{pname} = In{pname}:GetValue(req).Value")
750
+ param_struct.append(f" {ptype} {pname};")
751
+
752
+ inputs_block = "\n".join(inputs)
753
+ set_block = "\n".join(set_lines)
754
+ struct_body = "\n".join(param_struct)
755
+
756
+ return header(name, "dctl_kernel", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
757
+ REGS_Category = "Fuses\\\\MCP",
758
+ REGS_OpIconString = "{name[:4]}",
759
+ REGS_OpDescription = "DCTL GPU kernel",
760
+ }})
761
+
762
+ KernelParams = [[
763
+ {struct_body}
764
+ int srcsize[2];
765
+ ]]
766
+
767
+ KernelSource = [[
768
+ __KERNEL__ void {name}Kernel(__CONSTANTREF__ KernelParams *params,
769
+ __TEXTURE2D__ src,
770
+ __TEXTURE2D_WRITE__ dst)
771
+ {{
772
+ DEFINE_KERNEL_ITERATORS_XY(x, y);
773
+ if (x < params->srcsize[0] && y < params->srcsize[1])
774
+ {{
775
+ {kernel}
776
+ }}
777
+ }}
778
+ ]]
779
+
780
+ function Create()
781
+ {inputs_block}
782
+
783
+ InImage = self:AddInput("Input", "Input", {{
784
+ LINKID_DataType = "Image",
785
+ LINK_Main = 1,
786
+ }})
787
+
788
+ OutImage = self:AddOutput("Output", "Output", {{
789
+ LINKID_DataType = "Image",
790
+ LINK_Main = 1,
791
+ }})
792
+ end
793
+
794
+ function Process(req)
795
+ local img = InImage:GetValue(req)
796
+ local out = Image({{ IMG_Like = img }})
797
+
798
+ local node = DVIPComputeNode(req, "{name}Kernel", KernelSource,
799
+ "KernelParams", KernelParams)
800
+ local paramblock = node:GetParamBlock(KernelParams)
801
+ {set_block}
802
+ paramblock.srcsize[0] = img.DataWindow:Width()
803
+ paramblock.srcsize[1] = img.DataWindow:Height()
804
+ node:SetParamBlock(paramblock)
805
+
806
+ node:AddInput("src", img)
807
+ node:AddOutput("dst", out)
808
+ node:AddSampler("RowSampler", TEX_FILTER_MODE_LINEAR,
809
+ TEX_ADDRESS_MODE_CLAMP, TEX_NORMALIZED_COORDS_FALSE)
810
+
811
+ local ok = node:RunSession(req)
812
+ if not ok then
813
+ print("DCTL kernel run failed for {name}")
814
+ end
815
+
816
+ OutImage:Set(req, out)
817
+ end
818
+ '''
819
+
820
+
821
+ def source_generator(name: str, options: Optional[Dict[str, Any]] = None) -> str:
822
+ """Source-only Fuse with no image input. Outputs procedurally-generated content.
823
+
824
+ options:
825
+ kind: 'noise' | 'gradient' | 'checkerboard' | 'solid' (default 'noise')
826
+ width: int (default 1920)
827
+ height: int (default 1080)
828
+
829
+ Uses CT_Tool with REG_Source_*Ctrls registration so Fusion treats this as
830
+ a source generator (no image input slot, sized by the project). See the
831
+ SDK's GPUSampleFuse example (p. 140).
832
+ """
833
+ options = options or {}
834
+ kind = options.get("kind", "noise")
835
+ if kind not in ("noise", "gradient", "checkerboard", "solid"):
836
+ raise ValueError(f"Invalid source_generator kind '{kind}'. "
837
+ "Valid: noise, gradient, checkerboard, solid")
838
+ width = int(options.get("width", 1920))
839
+ height = int(options.get("height", 1080))
840
+
841
+ if kind == "noise":
842
+ body = ''' local p = Pixel({A = 1})
843
+ for y = 0, out.Height - 1 do
844
+ for x = 0, out.Width - 1 do
845
+ p.R = math.random()
846
+ p.G = math.random()
847
+ p.B = math.random()
848
+ out:SetPixel(x, y, p)
849
+ end
850
+ end'''
851
+ elif kind == "gradient":
852
+ body = ''' local p = Pixel({A = 1})
853
+ for y = 0, out.Height - 1 do
854
+ local v = y / (out.Height - 1)
855
+ for x = 0, out.Width - 1 do
856
+ local u = x / (out.Width - 1)
857
+ p.R = u
858
+ p.G = v
859
+ p.B = 1 - u
860
+ out:SetPixel(x, y, p)
861
+ end
862
+ end'''
863
+ elif kind == "checkerboard":
864
+ body = ''' local p = Pixel({A = 1})
865
+ local cell = 64
866
+ for y = 0, out.Height - 1 do
867
+ for x = 0, out.Width - 1 do
868
+ local on = ((math.floor(x / cell) + math.floor(y / cell)) % 2) == 0
869
+ local v = on and 1 or 0
870
+ p.R, p.G, p.B = v, v, v
871
+ out:SetPixel(x, y, p)
872
+ end
873
+ end'''
874
+ else: # solid
875
+ body = ''' local r = InColorR:GetValue(req).Value
876
+ local g = InColorG:GetValue(req).Value
877
+ local b = InColorB:GetValue(req).Value
878
+ out:Fill(Pixel({R = r, G = g, B = b, A = 1}))'''
879
+
880
+ extra_inputs = ""
881
+ if kind == "solid":
882
+ extra_inputs = (
883
+ _slider("InColorR", "Red", default=1.0, min_=0.0, max_=1.0)
884
+ + _slider("InColorG", "Green", default=1.0, min_=0.0, max_=1.0)
885
+ + _slider("InColorB", "Blue", default=1.0, min_=0.0, max_=1.0)
886
+ )
887
+
888
+ return header(name, "source_generator", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
889
+ REGS_Category = "Fuses\\\\MCP",
890
+ REGS_OpIconString = "{name[:4]}",
891
+ REGS_OpDescription = "Source generator ({kind})",
892
+ REG_NoBlendCtrls = true,
893
+ REG_NoMotionBlurCtrls = true,
894
+ REG_NoObjMatCtrls = true,
895
+ REG_OpNoMask = true,
896
+ REG_Source_GlobalCtrls = true,
897
+ REG_Source_SizeCtrls = true,
898
+ REG_Source_AspectCtrls = true,
899
+ REG_Source_DepthCtrls = true,
900
+ }})
901
+
902
+ function Create()
903
+ {extra_inputs}
904
+ OutImage = self:AddOutput("Output", "Output", {{
905
+ LINKID_DataType = "Image",
906
+ LINK_Main = 1,
907
+ }})
908
+ end
909
+
910
+ function Process(req)
911
+ local realwidth = Width
912
+ local realheight = Height
913
+ Width = Width / Scale
914
+ Height = Height / Scale
915
+ Scale = 1
916
+
917
+ local imgattrs = {{
918
+ IMG_Document = self.Comp,
919
+ {{ IMG_Channel = "Red" }},
920
+ {{ IMG_Channel = "Green" }},
921
+ {{ IMG_Channel = "Blue" }},
922
+ {{ IMG_Channel = "Alpha" }},
923
+ IMG_Width = Width,
924
+ IMG_Height = Height,
925
+ IMG_XScale = XAspect,
926
+ IMG_YScale = YAspect,
927
+ IMAT_OriginalWidth = realwidth,
928
+ IMAT_OriginalHeight = realheight,
929
+ IMG_Quality = not req:IsQuick(),
930
+ IMG_MotionBlurQuality = not req:IsNoMotionBlur(),
931
+ }}
932
+ if not req:IsStampOnly() then
933
+ imgattrs.IMG_ProxyScale = 1
934
+ end
935
+ if SourceDepth ~= 0 then
936
+ imgattrs.IMG_Depth = SourceDepth
937
+ end
938
+
939
+ local out = Image(imgattrs)
940
+ {body}
941
+
942
+ OutImage:Set(req, out)
943
+ end
944
+ '''
945
+
946
+
947
+ def time_displace(name: str, options: Optional[Dict[str, Any]] = None) -> str:
948
+ """Temporal effect Fuse: outputs the input at a time-shifted frame.
949
+
950
+ options:
951
+ default_offset: integer frame offset (default -1, i.e. previous frame)
952
+
953
+ Uses InImage:GetSource(req.Time + offset) to read a different frame than
954
+ the current request time. Foundation for motion blur, frame averaging,
955
+ deja-vu, and time-displacement effects. See SDK p. 45.
956
+ """
957
+ options = options or {}
958
+ default_offset = int(options.get("default_offset", -1))
959
+
960
+ return header(name, "time_displace", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
961
+ REGS_Category = "Fuses\\\\MCP",
962
+ REGS_OpIconString = "{name[:4]}",
963
+ REGS_OpDescription = "Time displace (temporal frame shift)",
964
+ }})
965
+
966
+ function Create()
967
+ InOffset = self:AddInput("Frame Offset", "FrameOffset", {{
968
+ LINKID_DataType = "Number",
969
+ INPID_InputControl = "SliderControl",
970
+ INP_Integer = true,
971
+ INP_Default = {default_offset},
972
+ INP_MinScale = -30,
973
+ INP_MaxScale = 30,
974
+ }})
975
+
976
+ InImage = self:AddInput("Input", "Input", {{
977
+ LINKID_DataType = "Image",
978
+ LINK_Main = 1,
979
+ INP_SendRequest = false,
980
+ }})
981
+
982
+ OutImage = self:AddOutput("Output", "Output", {{
983
+ LINKID_DataType = "Image",
984
+ LINK_Main = 1,
985
+ }})
986
+ end
987
+
988
+ function Process(req)
989
+ local offset = InOffset:GetValue(req).Value
990
+ -- GetSource fetches the input at a frame other than the current request time.
991
+ local img = InImage:GetSource(req.Time + offset)
992
+ if img == nil then
993
+ -- No frame available at that time (e.g. start/end of timeline);
994
+ -- fall back to current frame.
995
+ img = InImage:GetValue(req)
996
+ end
997
+ OutImage:Set(req, img)
998
+ end
999
+ '''
1000
+
1001
+
1002
+ def channel_op(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1003
+ """Channel operation Fuse with full aux-channel exposure.
1004
+
1005
+ options:
1006
+ operation: 'Copy' | 'Add' | 'Subtract' | 'Multiply' | 'Divide' |
1007
+ 'Max' | 'Min' | 'Invert' | 'Difference' | 'SignedAdd' |
1008
+ 'Threshold' (default 'Copy')
1009
+ rgba_only: bool — if true, only RGBA controls are exposed
1010
+ (default true). Set false to also expose Z/Object/Position
1011
+ channel mappings.
1012
+
1013
+ Wraps Image:ChannelOpOf (SDK pp. 71-72) which gives fine-grained control
1014
+ over how individual channels combine across two images. Real workflow:
1015
+ multipass / VFX deep-comp where you need to remap aux channels.
1016
+ """
1017
+ options = options or {}
1018
+ operation = options.get("operation", "Copy")
1019
+ valid_ops = ("Copy", "Add", "Subtract", "Multiply", "Divide", "Max", "Min",
1020
+ "Invert", "Difference", "SignedAdd", "Threshold")
1021
+ if operation not in valid_ops:
1022
+ raise ValueError(f"Invalid channel_op operation '{operation}'. "
1023
+ f"Valid: {list(valid_ops)}")
1024
+ rgba_only = bool(options.get("rgba_only", True))
1025
+
1026
+ aux_channels = ""
1027
+ aux_doc = ""
1028
+ if not rgba_only:
1029
+ aux_doc = (
1030
+ "\n-- Pixel struct also exposes Z, U/V/W, Coverage, ObjectID,\n"
1031
+ "-- MaterialID, NX/NY/NZ, BgR/G/B/A, VectorX/Y, BackVectorX/Y,\n"
1032
+ "-- DisparityX/Y, PositionX/Y/Z. Reference any of these in the\n"
1033
+ "-- options table values (e.g. R = 'Fg.Z' to map Z buffer into Red).\n"
1034
+ )
1035
+
1036
+ return header(name, "channel_op", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
1037
+ REGS_Category = "Fuses\\\\MCP",
1038
+ REGS_OpIconString = "{name[:4]}",
1039
+ REGS_OpDescription = "Channel operation ({operation})",
1040
+ }})
1041
+ {aux_doc}
1042
+ function Create()
1043
+ InOperation = self:AddInput("Operation", "Operation", {{
1044
+ LINKID_DataType = "Number",
1045
+ INPID_InputControl = "ComboControl",
1046
+ INP_Integer = true,
1047
+ INP_Default = 0,
1048
+ {{ CCS_AddString = "Copy" }},
1049
+ {{ CCS_AddString = "Add" }},
1050
+ {{ CCS_AddString = "Subtract" }},
1051
+ {{ CCS_AddString = "Multiply" }},
1052
+ {{ CCS_AddString = "Divide" }},
1053
+ {{ CCS_AddString = "Max" }},
1054
+ {{ CCS_AddString = "Min" }},
1055
+ {{ CCS_AddString = "Invert" }},
1056
+ {{ CCS_AddString = "Difference" }},
1057
+ {{ CCS_AddString = "SignedAdd" }},
1058
+ {{ CCS_AddString = "Threshold" }},
1059
+ }})
1060
+
1061
+ InBackground = self:AddInput("Background", "Background", {{
1062
+ LINKID_DataType = "Image",
1063
+ LINK_Main = 1,
1064
+ }})
1065
+
1066
+ InForeground = self:AddInput("Foreground", "Foreground", {{
1067
+ LINKID_DataType = "Image",
1068
+ LINK_Main = 2,
1069
+ INP_Required = false,
1070
+ }})
1071
+
1072
+ OutImage = self:AddOutput("Output", "Output", {{
1073
+ LINKID_DataType = "Image",
1074
+ LINK_Main = 1,
1075
+ }})
1076
+ end
1077
+
1078
+ local op_table = {{
1079
+ [0] = "Copy", "Add", "Subtract", "Multiply", "Divide",
1080
+ "Max", "Min", "Invert", "Difference", "SignedAdd", "Threshold",
1081
+ }}
1082
+
1083
+ function Process(req)
1084
+ local bg = InBackground:GetValue(req)
1085
+ local fg = InForeground:GetValue(req)
1086
+ if fg == nil then
1087
+ fg = bg
1088
+ end
1089
+ local op = op_table[InOperation:GetValue(req).Value]
1090
+
1091
+ -- Default mapping: copy each channel from foreground.
1092
+ local out = bg:ChannelOpOf(op, fg, {{
1093
+ R = "Fg.R", G = "Fg.G", B = "Fg.B", A = "Fg.A",
1094
+ }})
1095
+
1096
+ OutImage:Set(req, out)
1097
+ end
1098
+ '''
1099
+
1100
+
1101
+ def spatial_warp(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1102
+ """Per-pixel spatial warp Fuse. Each output pixel samples a different
1103
+ source location.
1104
+
1105
+ options:
1106
+ edge_mode: 'wrap' | 'clamp' | 'black' (default 'wrap')
1107
+ Selects SamplePixelW / SamplePixelD / SamplePixelB.
1108
+ warp: 'sine' | 'scatter' | 'pinch' (default 'sine')
1109
+
1110
+ Reference implementation: SDK Example7_Sampling.fuse (pp. 37–38).
1111
+ """
1112
+ options = options or {}
1113
+ edge_mode = options.get("edge_mode", "wrap")
1114
+ if edge_mode not in ("wrap", "clamp", "black"):
1115
+ raise ValueError(f"Invalid edge_mode '{edge_mode}'. "
1116
+ "Valid: wrap, clamp, black")
1117
+ warp = options.get("warp", "sine")
1118
+ if warp not in ("sine", "scatter", "pinch"):
1119
+ raise ValueError(f"Invalid warp '{warp}'. "
1120
+ "Valid: sine, scatter, pinch")
1121
+ sample_fn = {"wrap": "SamplePixelW", "clamp": "SamplePixelD",
1122
+ "black": "SamplePixelB"}[edge_mode]
1123
+
1124
+ if warp == "sine":
1125
+ warp_body = ''' local xt = x - amp * math.sin(y * freq + phase)
1126
+ local yt = y - amp * math.sin(x * freq + phase)
1127
+ img:{sample_fn}(xt, yt, sp)'''
1128
+ elif warp == "scatter":
1129
+ warp_body = ''' img:GetPixel(x, y, sp)
1130
+ local xt = x - amp * 5 * (sp.R - 0.5)
1131
+ local yt = y - amp * 5 * (sp.B - 0.5)
1132
+ img:{sample_fn}(xt, yt, sp)'''
1133
+ else: # pinch
1134
+ warp_body = ''' local dx = x - cx
1135
+ local dy = y - cy
1136
+ local r = math.sqrt(dx*dx + dy*dy)
1137
+ local k = 1 + amp * math.exp(-r / (freq * 100))
1138
+ img:{sample_fn}(cx + dx / k, cy + dy / k, sp)'''
1139
+ warp_body = warp_body.replace("{sample_fn}", sample_fn)
1140
+
1141
+ return header(name, "spatial_warp", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
1142
+ REGS_Category = "Fuses\\\\MCP",
1143
+ REGS_OpIconString = "{name[:4]}",
1144
+ REGS_OpDescription = "Spatial warp ({warp}, {edge_mode})",
1145
+ }})
1146
+
1147
+ function Create()
1148
+ InAmp = self:AddInput("Amplitude", "Amplitude", {{
1149
+ LINKID_DataType = "Number",
1150
+ INPID_InputControl = "SliderControl",
1151
+ INP_Default = 5.0,
1152
+ INP_MinScale = 0.0,
1153
+ INP_MaxScale = 50.0,
1154
+ }})
1155
+
1156
+ InFreq = self:AddInput("Frequency", "Frequency", {{
1157
+ LINKID_DataType = "Number",
1158
+ INPID_InputControl = "SliderControl",
1159
+ INP_Default = 0.05,
1160
+ INP_MinScale = 0.0,
1161
+ INP_MaxScale = 1.0,
1162
+ }})
1163
+
1164
+ InPhase = self:AddInput("Phase", "Phase", {{
1165
+ LINKID_DataType = "Number",
1166
+ INPID_InputControl = "ScrewControl",
1167
+ INP_Default = 0.0,
1168
+ INP_MinScale = 0.0,
1169
+ INP_MaxScale = 6.283,
1170
+ }})
1171
+
1172
+ InImage = self:AddInput("Input", "Input", {{
1173
+ LINKID_DataType = "Image",
1174
+ LINK_Main = 1,
1175
+ }})
1176
+
1177
+ OutImage = self:AddOutput("Output", "Output", {{
1178
+ LINKID_DataType = "Image",
1179
+ LINK_Main = 1,
1180
+ }})
1181
+ end
1182
+
1183
+ function Process(req)
1184
+ local img = InImage:GetValue(req)
1185
+ local amp = InAmp:GetValue(req).Value
1186
+ local freq = InFreq:GetValue(req).Value
1187
+ local phase = InPhase:GetValue(req).Value
1188
+
1189
+ local out = Image({{ IMG_Like = img }})
1190
+ local sp = Pixel()
1191
+ local cx = img.Width / 2
1192
+ local cy = img.Height / 2
1193
+
1194
+ for y = 0, img.Height - 1 do
1195
+ for x = 0, img.Width - 1 do
1196
+ {warp_body}
1197
+ out:SetPixel(x, y, sp)
1198
+ end
1199
+ end
1200
+
1201
+ OutImage:Set(req, out)
1202
+ end
1203
+ '''
1204
+
1205
+
1206
+ def builtin_blur(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1207
+ """Blur Fuse with full BLUR_Type / per-channel-mask exposure.
1208
+
1209
+ options:
1210
+ default_type: 'Box' | 'Soften' | 'Bartlett' | 'Sharpen' | 'Gaussian'
1211
+ | 'Hilight' | 'Blend' | 'Solarise' (default 'Gaussian')
1212
+
1213
+ Wraps Image:Blur with all 8 documented BLUR_Type values + per-channel
1214
+ enable/disable. Stock Blur tool is great but hides MultiBox/Solarise/
1215
+ FastGaussian. SDK pp. 69-70.
1216
+ """
1217
+ options = options or {}
1218
+ valid_types = ("Box", "Soften", "Bartlett", "Sharpen", "Gaussian",
1219
+ "Hilight", "Blend", "Solarise")
1220
+ default_type = options.get("default_type", "Gaussian")
1221
+ if default_type not in valid_types:
1222
+ raise ValueError(f"Invalid default_type '{default_type}'. "
1223
+ f"Valid: {list(valid_types)}")
1224
+ default_idx = valid_types.index(default_type)
1225
+
1226
+ return header(name, "builtin_blur", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
1227
+ REGS_Category = "Fuses\\\\MCP",
1228
+ REGS_OpIconString = "{name[:4]}",
1229
+ REGS_OpDescription = "Configurable blur",
1230
+ }})
1231
+
1232
+ function Create()
1233
+ InType = self:AddInput("Filter", "Filter", {{
1234
+ LINKID_DataType = "Number",
1235
+ INPID_InputControl = "ComboControl",
1236
+ INP_Integer = true,
1237
+ INP_Default = {default_idx},
1238
+ {{ CCS_AddString = "Box" }},
1239
+ {{ CCS_AddString = "Soften" }},
1240
+ {{ CCS_AddString = "Bartlett" }},
1241
+ {{ CCS_AddString = "Sharpen" }},
1242
+ {{ CCS_AddString = "Gaussian" }},
1243
+ {{ CCS_AddString = "Hilight" }},
1244
+ {{ CCS_AddString = "Blend" }},
1245
+ {{ CCS_AddString = "Solarise" }},
1246
+ }})
1247
+
1248
+ InSize = self:AddInput("Size", "Size", {{
1249
+ LINKID_DataType = "Number",
1250
+ INPID_InputControl = "SliderControl",
1251
+ INP_Default = 5.0,
1252
+ INP_MinScale = 0.0,
1253
+ INP_MaxScale = 100.0,
1254
+ }})
1255
+
1256
+ InRed = self:AddInput("Red", "Red", {{
1257
+ LINKID_DataType = "Number", INPID_InputControl = "CheckboxControl",
1258
+ INP_Integer = true, INP_Default = 1.0, ICD_Width = 0.25,
1259
+ }})
1260
+ InGreen = self:AddInput("Green", "Green", {{
1261
+ LINKID_DataType = "Number", INPID_InputControl = "CheckboxControl",
1262
+ INP_Integer = true, INP_Default = 1.0, ICD_Width = 0.25,
1263
+ }})
1264
+ InBlue = self:AddInput("Blue", "Blue", {{
1265
+ LINKID_DataType = "Number", INPID_InputControl = "CheckboxControl",
1266
+ INP_Integer = true, INP_Default = 1.0, ICD_Width = 0.25,
1267
+ }})
1268
+ InAlpha = self:AddInput("Alpha", "Alpha", {{
1269
+ LINKID_DataType = "Number", INPID_InputControl = "CheckboxControl",
1270
+ INP_Integer = true, INP_Default = 0.0, ICD_Width = 0.25,
1271
+ }})
1272
+
1273
+ InImage = self:AddInput("Input", "Input", {{
1274
+ LINKID_DataType = "Image",
1275
+ LINK_Main = 1,
1276
+ }})
1277
+
1278
+ OutImage = self:AddOutput("Output", "Output", {{
1279
+ LINKID_DataType = "Image",
1280
+ LINK_Main = 1,
1281
+ }})
1282
+ end
1283
+
1284
+ local type_table = {{
1285
+ [0] = "Box", "Soften", "Bartlett", "Sharpen", "Gaussian",
1286
+ "Hilight", "Blend", "Solarise",
1287
+ }}
1288
+
1289
+ function Process(req)
1290
+ local img = InImage:GetValue(req)
1291
+ local size = InSize:GetValue(req).Value
1292
+ local out = Image({{ IMG_Like = img }})
1293
+
1294
+ img:Blur(out, {{
1295
+ BLUR_Type = type_table[InType:GetValue(req).Value],
1296
+ BLUR_Red = InRed:GetValue(req).Value > 0.5,
1297
+ BLUR_Green = InGreen:GetValue(req).Value > 0.5,
1298
+ BLUR_Blue = InBlue:GetValue(req).Value > 0.5,
1299
+ BLUR_Alpha = InAlpha:GetValue(req).Value > 0.5,
1300
+ BLUR_XSize = size / img.OriginalWidth,
1301
+ BLUR_YSize = size / img.OriginalWidth,
1302
+ }})
1303
+
1304
+ OutImage:Set(req, out)
1305
+ end
1306
+ '''
1307
+
1308
+
1309
+ def builtin_resize(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1310
+ """Resize Fuse with all 12 documented filter methods + Lanczos windowing.
1311
+
1312
+ Stock Resize tool exposes a subset; this gives full control including
1313
+ Lanczos+window combinations for production scaling. SDK p. 91.
1314
+ """
1315
+ options = options or {}
1316
+ return header(name, "builtin_resize", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
1317
+ REGS_Category = "Fuses\\\\MCP",
1318
+ REGS_OpIconString = "{name[:4]}",
1319
+ REGS_OpDescription = "Configurable resize",
1320
+ }})
1321
+
1322
+ function Create()
1323
+ InFilter = self:AddInput("Filter", "Filter", {{
1324
+ LINKID_DataType = "Number",
1325
+ INPID_InputControl = "ComboControl",
1326
+ INP_Integer = true,
1327
+ INP_Default = 11, -- Lanczos
1328
+ {{ CCS_AddString = "TopLeft" }},
1329
+ {{ CCS_AddString = "Nearest" }},
1330
+ {{ CCS_AddString = "Box" }},
1331
+ {{ CCS_AddString = "Linear" }},
1332
+ {{ CCS_AddString = "BiLinear" }},
1333
+ {{ CCS_AddString = "Quadratic" }},
1334
+ {{ CCS_AddString = "BiCubic" }},
1335
+ {{ CCS_AddString = "Cubic" }},
1336
+ {{ CCS_AddString = "BSpline" }},
1337
+ {{ CCS_AddString = "CatmullRom" }},
1338
+ {{ CCS_AddString = "Gaussian" }},
1339
+ {{ CCS_AddString = "Mitchell" }},
1340
+ {{ CCS_AddString = "Lanczos" }},
1341
+ {{ CCS_AddString = "Sinc" }},
1342
+ {{ CCS_AddString = "Bessel" }},
1343
+ }})
1344
+
1345
+ InWindow = self:AddInput("Window", "Window", {{
1346
+ LINKID_DataType = "Number",
1347
+ INPID_InputControl = "ComboControl",
1348
+ INP_Integer = true,
1349
+ INP_Default = 0,
1350
+ {{ CCS_AddString = "Hanning" }},
1351
+ {{ CCS_AddString = "Hamming" }},
1352
+ {{ CCS_AddString = "Blackman" }},
1353
+ {{ CCS_AddString = "Kaiser" }},
1354
+ }})
1355
+
1356
+ InWidth = self:AddInput("Width", "Width", {{
1357
+ LINKID_DataType = "Number",
1358
+ INPID_InputControl = "SliderControl",
1359
+ INP_Integer = true,
1360
+ INP_Default = 1920,
1361
+ INP_MinScale = 1, INP_MaxScale = 7680,
1362
+ }})
1363
+
1364
+ InHeight = self:AddInput("Height", "Height", {{
1365
+ LINKID_DataType = "Number",
1366
+ INPID_InputControl = "SliderControl",
1367
+ INP_Integer = true,
1368
+ INP_Default = 1080,
1369
+ INP_MinScale = 1, INP_MaxScale = 4320,
1370
+ }})
1371
+
1372
+ InImage = self:AddInput("Input", "Input", {{
1373
+ LINKID_DataType = "Image",
1374
+ LINK_Main = 1,
1375
+ }})
1376
+
1377
+ OutImage = self:AddOutput("Output", "Output", {{
1378
+ LINKID_DataType = "Image",
1379
+ LINK_Main = 1,
1380
+ }})
1381
+ end
1382
+
1383
+ local filter_table = {{
1384
+ [0] = "TopLeft", "Nearest", "Box", "Linear", "BiLinear",
1385
+ "Quadratic", "BiCubic", "Cubic", "BSpline", "CatmullRom",
1386
+ "Gaussian", "Mitchell", "Lanczos", "Sinc", "Bessel",
1387
+ }}
1388
+ local window_table = {{ [0] = "Hanning", "Hamming", "Blackman", "Kaiser" }}
1389
+
1390
+ function Process(req)
1391
+ local img = InImage:GetValue(req)
1392
+ local filter = filter_table[InFilter:GetValue(req).Value]
1393
+ local w = InWidth:GetValue(req).Value
1394
+ local h = InHeight:GetValue(req).Value
1395
+
1396
+ local out = Image({{ IMG_Like = img, IMG_Width = w, IMG_Height = h }})
1397
+ img:Resize(out, {{
1398
+ RSZ_Filter = filter,
1399
+ RSZ_Window = window_table[InWindow:GetValue(req).Value],
1400
+ RSZ_Width = w,
1401
+ RSZ_Height = h,
1402
+ }})
1403
+
1404
+ OutImage:Set(req, out)
1405
+ end
1406
+ '''
1407
+
1408
+
1409
+ def controls_demo(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1410
+ """Reference Fuse demonstrating ALL UI control types in nested groups.
1411
+
1412
+ Educational template — when an LLM (or human) wants a worked example of
1413
+ every available UI control, generate this and read the source. Mirrors
1414
+ SDK Example2_UIControls.fuse.
1415
+ """
1416
+ return header(name, "controls_demo", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
1417
+ REGS_Category = "Fuses\\\\MCP",
1418
+ REGS_OpIconString = "{name[:4]}",
1419
+ REGS_OpDescription = "Reference: every UI control type",
1420
+ }})
1421
+
1422
+ function Create()
1423
+ self:AddControlPage("Controls")
1424
+
1425
+ self:BeginControlNest("Sliders", "Sliders", true, {{}})
1426
+ InSliderFloat = self:AddInput("Slider Float", "SliderFloat", {{
1427
+ LINKID_DataType = "Number", INPID_InputControl = "SliderControl",
1428
+ INP_Default = 0.5, INP_MinScale = 0.0, INP_MaxScale = 1.0,
1429
+ }})
1430
+ InSliderInt = self:AddInput("Slider Int", "SliderInt", {{
1431
+ LINKID_DataType = "Number", INPID_InputControl = "SliderControl",
1432
+ INP_Integer = true, INP_Default = 1, INP_MinScale = 0, INP_MaxScale = 10,
1433
+ }})
1434
+ InScrew = self:AddInput("Thumbwheel", "Thumbwheel", {{
1435
+ LINKID_DataType = "Number", INPID_InputControl = "ScrewControl",
1436
+ INP_Default = 0,
1437
+ }})
1438
+ InRangeLow = self:AddInput("Range Low", "RangeLow", {{
1439
+ LINKID_DataType = "Number", INPID_InputControl = "RangeControl",
1440
+ IC_ControlGroup = 1, IC_ControlID = 0, INP_Default = 0.1,
1441
+ }})
1442
+ InRangeHigh = self:AddInput("Range High", "RangeHigh", {{
1443
+ LINKID_DataType = "Number", INPID_InputControl = "RangeControl",
1444
+ IC_ControlGroup = 1, IC_ControlID = 1, INP_Default = 0.9,
1445
+ }})
1446
+ self:EndControlNest()
1447
+
1448
+ self:BeginControlNest("Toggles & Buttons", "Toggles", false, {{}})
1449
+ InCheck = self:AddInput("Checkbox", "Checkbox", {{
1450
+ LINKID_DataType = "Number", INPID_InputControl = "CheckboxControl",
1451
+ INP_Integer = true, INP_Default = 1.0,
1452
+ }})
1453
+ InCombo = self:AddInput("Combo", "Combo", {{
1454
+ LINKID_DataType = "Number", INPID_InputControl = "ComboControl",
1455
+ INP_Integer = true, INP_Default = 0,
1456
+ {{ CCS_AddString = "Option A" }},
1457
+ {{ CCS_AddString = "Option B" }},
1458
+ {{ CCS_AddString = "Option C" }},
1459
+ }})
1460
+ InMulti = self:AddInput("Multi-button", "Multi", {{
1461
+ LINKID_DataType = "Number", INPID_InputControl = "MultiButtonControl",
1462
+ INP_Default = 0, INP_Integer = true,
1463
+ {{ MBTNC_AddButton = "X", MBTNCD_ButtonWidth = 0.33 }},
1464
+ {{ MBTNC_AddButton = "Y", MBTNCD_ButtonWidth = 0.33 }},
1465
+ {{ MBTNC_AddButton = "Z", MBTNCD_ButtonWidth = 0.33 }},
1466
+ }})
1467
+ InButton = self:AddInput("Button", "Button", {{
1468
+ LINKID_DataType = "Text", INPID_InputControl = "ButtonControl",
1469
+ INP_DoNotifyChanged = true, INP_External = false,
1470
+ }})
1471
+ self:EndControlNest()
1472
+
1473
+ self:BeginControlNest("Color & Gradient", "Colors", false, {{}})
1474
+ InGrad = self:AddInput("Gradient", "Gradient", {{
1475
+ LINKID_DataType = "Gradient", INPID_InputControl = "GradientControl",
1476
+ INP_DelayDefault = true,
1477
+ }})
1478
+ InR = self:AddInput("Red", "Red", {{
1479
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
1480
+ IC_ControlGroup = 2, IC_ControlID = 0, INP_Default = 1.0,
1481
+ }})
1482
+ InG = self:AddInput("Green", "Green", {{
1483
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
1484
+ IC_ControlGroup = 2, IC_ControlID = 1, INP_Default = 1.0,
1485
+ }})
1486
+ InB = self:AddInput("Blue", "Blue", {{
1487
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
1488
+ IC_ControlGroup = 2, IC_ControlID = 2, INP_Default = 1.0,
1489
+ }})
1490
+ self:EndControlNest()
1491
+
1492
+ self:BeginControlNest("Text & File", "Text", false, {{}})
1493
+ InText = self:AddInput("Text", "Text", {{
1494
+ LINKID_DataType = "Text", INPID_InputControl = "TextEditControl",
1495
+ TEC_Lines = 2,
1496
+ }})
1497
+ InFile = self:AddInput("File", "File", {{
1498
+ LINKID_DataType = "Text", INPID_InputControl = "FileControl",
1499
+ FC_ClipBrowse = true,
1500
+ }})
1501
+ self:EndControlNest()
1502
+
1503
+ self:BeginControlNest("Onscreen", "Onscreen", false, {{}})
1504
+ InCenter = self:AddInput("Center", "Center", {{
1505
+ LINKID_DataType = "Point", INPID_InputControl = "OffsetControl",
1506
+ INPID_PreviewControl = "CrosshairControl",
1507
+ INP_DefaultX = 0.5, INP_DefaultY = 0.5,
1508
+ }})
1509
+ self:EndControlNest()
1510
+
1511
+ InImage = self:AddInput("Input", "Input", {{
1512
+ LINKID_DataType = "Image", LINK_Main = 1, INP_Required = false,
1513
+ }})
1514
+
1515
+ OutImage = self:AddOutput("Output", "Output", {{
1516
+ LINKID_DataType = "Image", LINK_Main = 1,
1517
+ }})
1518
+ end
1519
+
1520
+ function NotifyChanged(inp, param, time)
1521
+ if inp == InButton and param.Value == 1 then
1522
+ print("Button pressed at frame " .. tostring(time))
1523
+ end
1524
+ end
1525
+
1526
+ function Process(req)
1527
+ local img = InImage:GetValue(req)
1528
+ if img == nil then
1529
+ -- Stub output when no image connected. Caller should normally connect one.
1530
+ local stub = Image({{
1531
+ IMG_Document = self.Comp,
1532
+ {{IMG_Channel = "Red"}}, {{IMG_Channel = "Green"}},
1533
+ {{IMG_Channel = "Blue"}}, {{IMG_Channel = "Alpha"}},
1534
+ IMG_Width = 64, IMG_Height = 64,
1535
+ IMG_XScale = 1.0, IMG_YScale = 1.0,
1536
+ }})
1537
+ stub:Fill(Pixel({{R = 0, G = 0, B = 0, A = 0}}))
1538
+ OutImage:Set(req, stub)
1539
+ return
1540
+ end
1541
+ OutImage:Set(req, img)
1542
+ end
1543
+ '''
1544
+
1545
+
1546
+ def shape_generator(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1547
+ """Vector shape Fuse: draws a parametric shape (circle/rect/star) as a
1548
+ filled image overlay.
1549
+
1550
+ options:
1551
+ shape: 'circle' | 'rect' | 'star' (default 'circle')
1552
+
1553
+ Demonstrates Shape + AddRectangle / MoveTo / LineTo / OutlineOfShape +
1554
+ ChannelStyle + ImageChannel + PutToImage. See SDK Example5_Shapes.
1555
+ """
1556
+ options = options or {}
1557
+ shape_kind = options.get("shape", "circle")
1558
+ if shape_kind not in ("circle", "rect", "star"):
1559
+ raise ValueError(f"Invalid shape '{shape_kind}'. "
1560
+ "Valid: circle, rect, star")
1561
+
1562
+ if shape_kind == "circle":
1563
+ # Approximate circle using bezier segments.
1564
+ shape_body = ''' -- Circle approximated with 4 cubic beziers (k = 0.5522847498).
1565
+ local k = 0.5522847498 * radius
1566
+ sh:MoveTo(radius, 0)
1567
+ sh:BezierTo(0, radius, k, 0, radius, k)
1568
+ sh:BezierTo(-radius, 0, 0, radius, -k, 0)
1569
+ sh:BezierTo(0, -radius, -k, 0, -radius, -k)
1570
+ sh:BezierTo(radius, 0, 0, -radius, k, 0)
1571
+ sh:Close()'''
1572
+ elif shape_kind == "rect":
1573
+ shape_body = ''' sh:AddRectangle(-radius, -radius, radius, radius, 0.0, 8)'''
1574
+ else: # star
1575
+ shape_body = ''' -- 5-point star.
1576
+ local n = 5
1577
+ for i = 0, 2 * n - 1 do
1578
+ local a = (i / (2 * n)) * 2 * math.pi - math.pi / 2
1579
+ local r = (i % 2 == 0) and radius or radius * 0.4
1580
+ local x, y = math.cos(a) * r, math.sin(a) * r
1581
+ if i == 0 then sh:MoveTo(x, y) else sh:LineTo(x, y) end
1582
+ end
1583
+ sh:Close()'''
1584
+
1585
+ return header(name, "shape_generator", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
1586
+ REGS_Category = "Fuses\\\\MCP",
1587
+ REGS_OpIconString = "{name[:4]}",
1588
+ REGS_OpDescription = "Vector shape ({shape_kind})",
1589
+ }})
1590
+
1591
+ function Create()
1592
+ InCenter = self:AddInput("Center", "Center", {{
1593
+ LINKID_DataType = "Point",
1594
+ INPID_InputControl = "OffsetControl",
1595
+ INPID_PreviewControl = "CrosshairControl",
1596
+ INP_DefaultX = 0.5, INP_DefaultY = 0.5,
1597
+ }})
1598
+
1599
+ InRadius = self:AddInput("Radius", "Radius", {{
1600
+ LINKID_DataType = "Number",
1601
+ INPID_InputControl = "SliderControl",
1602
+ INP_Default = 0.2,
1603
+ INP_MinScale = 0.0, INP_MaxScale = 1.0,
1604
+ }})
1605
+
1606
+ InR = self:AddInput("Red", "Red", {{
1607
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
1608
+ IC_ControlGroup = 1, IC_ControlID = 0, INP_Default = 1.0,
1609
+ }})
1610
+ InG = self:AddInput("Green", "Green", {{
1611
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
1612
+ IC_ControlGroup = 1, IC_ControlID = 1, INP_Default = 1.0,
1613
+ }})
1614
+ InB = self:AddInput("Blue", "Blue", {{
1615
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
1616
+ IC_ControlGroup = 1, IC_ControlID = 2, INP_Default = 1.0,
1617
+ }})
1618
+ InA = self:AddInput("Alpha", "Alpha", {{
1619
+ LINKID_DataType = "Number", INPID_InputControl = "ColorControl",
1620
+ IC_ControlGroup = 1, IC_ControlID = 3, INP_Default = 1.0,
1621
+ }})
1622
+
1623
+ InImage = self:AddInput("Input", "Input", {{
1624
+ LINKID_DataType = "Image",
1625
+ LINK_Main = 1,
1626
+ INP_Required = false,
1627
+ }})
1628
+
1629
+ OutImage = self:AddOutput("Output", "Output", {{
1630
+ LINKID_DataType = "Image",
1631
+ LINK_Main = 1,
1632
+ }})
1633
+ end
1634
+
1635
+ function Process(req)
1636
+ local img = InImage:GetValue(req)
1637
+ if img == nil then
1638
+ img = Image({{
1639
+ IMG_Document = self.Comp,
1640
+ {{ IMG_Channel = "Red" }}, {{ IMG_Channel = "Green" }},
1641
+ {{ IMG_Channel = "Blue" }}, {{ IMG_Channel = "Alpha" }},
1642
+ IMG_Width = 1920, IMG_Height = 1080,
1643
+ IMG_XScale = 1.0, IMG_YScale = 1.0,
1644
+ }})
1645
+ img:Fill(Pixel({{R = 0, G = 0, B = 0, A = 0}}))
1646
+ end
1647
+
1648
+ local out = img:CopyOf()
1649
+ local center = InCenter:GetValue(req)
1650
+ local radius = InRadius:GetValue(req).Value
1651
+ local r = InR:GetValue(req).Value
1652
+ local g = InG:GetValue(req).Value
1653
+ local b = InB:GetValue(req).Value
1654
+ local a = InA:GetValue(req).Value
1655
+
1656
+ local ic = ImageChannel(out, 8)
1657
+ local fs = FillStyle()
1658
+ local cs = ChannelStyle()
1659
+ cs.Color = Pixel({{R = r, G = g, B = b, A = a}})
1660
+ ic:SetStyleFill(fs)
1661
+
1662
+ local mat = Matrix4()
1663
+ -- Y-squash conversion so on-screen Center lands correctly.
1664
+ local cy = center.Y * (out.Height * out.YScale) / (out.Width * out.XScale)
1665
+ mat:Move(center.X, cy, 0)
1666
+
1667
+ local sh = Shape()
1668
+ {shape_body}
1669
+ sh = sh:TransformOfShape(mat)
1670
+
1671
+ ic:ShapeFill(sh)
1672
+ ic:PutToImage("CM_Merge", cs)
1673
+
1674
+ OutImage:Set(req, out)
1675
+ end
1676
+ '''
1677
+
1678
+
1679
+ def notifychanged_demo(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1680
+ """Demonstrates dynamic UI: NotifyChanged hides/shows controls based on a
1681
+ checkbox state.
1682
+
1683
+ Reference pattern from SDK Example2_UIControls.fuse (pp. 23-24). When the
1684
+ `Show Details` checkbox is toggled, the detail sliders appear or hide.
1685
+ """
1686
+ return header(name, "notifychanged_demo", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
1687
+ REGS_Category = "Fuses\\\\MCP",
1688
+ REGS_OpIconString = "{name[:4]}",
1689
+ REGS_OpDescription = "NotifyChanged dynamic-UI demo",
1690
+ }})
1691
+
1692
+ function Create()
1693
+ InToggle = self:AddInput("Show Details", "ShowDetails", {{
1694
+ LINKID_DataType = "Number",
1695
+ INPID_InputControl = "CheckboxControl",
1696
+ INP_Integer = true,
1697
+ INP_Default = 0.0,
1698
+ INP_DoNotifyChanged = true,
1699
+ }})
1700
+
1701
+ InDetailA = self:AddInput("Detail A", "DetailA", {{
1702
+ LINKID_DataType = "Number",
1703
+ INPID_InputControl = "SliderControl",
1704
+ INP_Default = 0.5,
1705
+ IC_Visible = false,
1706
+ }})
1707
+
1708
+ InDetailB = self:AddInput("Detail B", "DetailB", {{
1709
+ LINKID_DataType = "Number",
1710
+ INPID_InputControl = "SliderControl",
1711
+ INP_Default = 0.5,
1712
+ IC_Visible = false,
1713
+ }})
1714
+
1715
+ InImage = self:AddInput("Input", "Input", {{
1716
+ LINKID_DataType = "Image",
1717
+ LINK_Main = 1,
1718
+ }})
1719
+
1720
+ OutImage = self:AddOutput("Output", "Output", {{
1721
+ LINKID_DataType = "Image",
1722
+ LINK_Main = 1,
1723
+ }})
1724
+ end
1725
+
1726
+ function NotifyChanged(inp, param, time)
1727
+ if inp == InToggle then
1728
+ local visible = (param.Value > 0.5)
1729
+ InDetailA:SetAttrs({{ IC_Visible = visible }})
1730
+ InDetailB:SetAttrs({{ IC_Visible = visible }})
1731
+ end
1732
+ end
1733
+
1734
+ function Process(req)
1735
+ OutImage:Set(req, InImage:GetValue(req))
1736
+ end
1737
+ '''
1738
+
1739
+
1740
+ def point_modifier(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1741
+ """Modifier producing a Point (X,Y) value over time. Use to drive
1742
+ on-screen positions like a Merge tool's Center input.
1743
+
1744
+ options:
1745
+ kind: 'orbit' | 'figure_eight' | 'spring' (default 'orbit')
1746
+
1747
+ Wires up via Inspector → right-click any Point input → Modify with → [name].
1748
+ Higher-risk than the Number modifier because CT_Modifier with Point output
1749
+ isn't covered by an SDK example.
1750
+ """
1751
+ options = options or {}
1752
+ kind = options.get("kind", "orbit")
1753
+ if kind not in ("orbit", "figure_eight", "spring"):
1754
+ raise ValueError(f"Invalid point_modifier kind '{kind}'. "
1755
+ "Valid: orbit, figure_eight, spring")
1756
+
1757
+ if kind == "orbit":
1758
+ formula = ''' local x = cx + r * math.cos(2 * math.pi * t * freq + phase)
1759
+ local y = cy + r * math.sin(2 * math.pi * t * freq + phase)'''
1760
+ elif kind == "figure_eight":
1761
+ formula = ''' local angle = 2 * math.pi * t * freq + phase
1762
+ local x = cx + r * math.sin(angle)
1763
+ local y = cy + r * math.sin(angle * 2) / 2'''
1764
+ else: # spring
1765
+ formula = ''' local angle = 2 * math.pi * t * freq + phase
1766
+ local damping = math.exp(-t * 0.1)
1767
+ local x = cx + r * math.cos(angle) * damping
1768
+ local y = cy + r * math.sin(angle) * damping'''
1769
+
1770
+ return header(name, "point_modifier", "modifier") + f'''FuRegisterClass("{name}", CT_Modifier, {{
1771
+ REGS_Category = "Fuses\\\\MCP",
1772
+ REGS_OpIconString = "{name[:4]}",
1773
+ REGS_OpDescription = "Point modifier ({kind})",
1774
+ }})
1775
+
1776
+ function Create()
1777
+ InCenterX = self:AddInput("Center X", "CenterX", {{
1778
+ LINKID_DataType = "Number",
1779
+ INPID_InputControl = "SliderControl",
1780
+ INP_Default = 0.5, INP_MinScale = 0.0, INP_MaxScale = 1.0,
1781
+ }})
1782
+
1783
+ InCenterY = self:AddInput("Center Y", "CenterY", {{
1784
+ LINKID_DataType = "Number",
1785
+ INPID_InputControl = "SliderControl",
1786
+ INP_Default = 0.5, INP_MinScale = 0.0, INP_MaxScale = 1.0,
1787
+ }})
1788
+
1789
+ InRadius = self:AddInput("Radius", "Radius", {{
1790
+ LINKID_DataType = "Number",
1791
+ INPID_InputControl = "SliderControl",
1792
+ INP_Default = 0.2, INP_MinScale = 0.0, INP_MaxScale = 1.0,
1793
+ }})
1794
+
1795
+ InFreq = self:AddInput("Frequency", "Frequency", {{
1796
+ LINKID_DataType = "Number",
1797
+ INPID_InputControl = "SliderControl",
1798
+ INP_Default = 0.05, INP_MinScale = 0.0, INP_MaxScale = 1.0,
1799
+ }})
1800
+
1801
+ InPhase = self:AddInput("Phase", "Phase", {{
1802
+ LINKID_DataType = "Number",
1803
+ INPID_InputControl = "ScrewControl",
1804
+ INP_Default = 0.0, INP_MinScale = 0.0, INP_MaxScale = 6.283,
1805
+ }})
1806
+
1807
+ OutPoint = self:AddOutput("Value", "Value", {{
1808
+ LINKID_DataType = "Point",
1809
+ LINK_Main = 1,
1810
+ }})
1811
+ end
1812
+
1813
+ function Process(req)
1814
+ local t = req.Time
1815
+ local cx = InCenterX:GetValue(req).Value
1816
+ local cy = InCenterY:GetValue(req).Value
1817
+ local r = InRadius:GetValue(req).Value
1818
+ local freq = InFreq:GetValue(req).Value
1819
+ local phase = InPhase:GetValue(req).Value
1820
+
1821
+ {formula}
1822
+
1823
+ OutPoint:Set(req, Point(x, y))
1824
+ end
1825
+ '''
1826
+
1827
+
1828
+ def variable_blur(name: str, options: Optional[Dict[str, Any]] = None) -> str:
1829
+ """Variable-radius box blur using Summed Area Tables (SAT). Each pixel
1830
+ can have a different blur radius.
1831
+
1832
+ options:
1833
+ radius_source: 'slider' | 'red' | 'alpha' (default 'slider')
1834
+ 'slider' = uniform radius from a slider input
1835
+ 'red' = per-pixel radius driven by red channel
1836
+ 'alpha' = per-pixel radius driven by alpha channel
1837
+
1838
+ Demonstrates UseSAT() + SampleAreaW() + RecycleSAT() pattern (SDK pp. 92-95,
1839
+ 99). Full per-pixel implementation — slower than Image:Blur for fixed
1840
+ radii but the right tool for radius-mapped effects (e.g. depth-of-field
1841
+ from a Z buffer).
1842
+ """
1843
+ options = options or {}
1844
+ radius_source = options.get("radius_source", "slider")
1845
+ if radius_source not in ("slider", "red", "alpha"):
1846
+ raise ValueError(f"Invalid radius_source '{radius_source}'. "
1847
+ "Valid: slider, red, alpha")
1848
+
1849
+ if radius_source == "slider":
1850
+ sample_radius = " local r = max_radius"
1851
+ elif radius_source == "red":
1852
+ sample_radius = ''' img:GetPixel(x, y, ref_pixel)
1853
+ local r = max_radius * ref_pixel.R'''
1854
+ else: # alpha
1855
+ sample_radius = ''' img:GetPixel(x, y, ref_pixel)
1856
+ local r = max_radius * ref_pixel.A'''
1857
+
1858
+ return header(name, "variable_blur", "tool") + f'''FuRegisterClass("{name}", CT_Tool, {{
1859
+ REGS_Category = "Fuses\\\\MCP",
1860
+ REGS_OpIconString = "{name[:4]}",
1861
+ REGS_OpDescription = "Variable-radius blur (SAT-based, {radius_source})",
1862
+ }})
1863
+
1864
+ function Create()
1865
+ InMaxRadius = self:AddInput("Max Radius", "MaxRadius", {{
1866
+ LINKID_DataType = "Number",
1867
+ INPID_InputControl = "SliderControl",
1868
+ INP_Integer = true,
1869
+ INP_Default = 16, INP_MinScale = 1, INP_MaxScale = 100,
1870
+ }})
1871
+
1872
+ InImage = self:AddInput("Input", "Input", {{
1873
+ LINKID_DataType = "Image",
1874
+ LINK_Main = 1,
1875
+ }})
1876
+
1877
+ OutImage = self:AddOutput("Output", "Output", {{
1878
+ LINKID_DataType = "Image",
1879
+ LINK_Main = 1,
1880
+ }})
1881
+ end
1882
+
1883
+ function Process(req)
1884
+ local img = InImage:GetValue(req)
1885
+ local max_radius = InMaxRadius:GetValue(req).Value
1886
+ local out = Image({{ IMG_Like = img }})
1887
+
1888
+ -- Build the Summed Area Table once. SampleArea functions need this.
1889
+ img:UseSAT()
1890
+
1891
+ local sp = Pixel()
1892
+ local ref_pixel = Pixel()
1893
+
1894
+ for y = 0, img.Height - 1 do
1895
+ if self.Status ~= "OK" then break end
1896
+ for x = 0, img.Width - 1 do
1897
+ {sample_radius}
1898
+ r = math.max(1, math.floor(r))
1899
+ -- SampleAreaW expects 4 corner points (rectangle vertices).
1900
+ img:SampleAreaW(x - r, y - r, x + r, y - r,
1901
+ x + r, y + r, x - r, y + r, sp)
1902
+ out:SetPixel(x, y, sp)
1903
+ end
1904
+ end
1905
+
1906
+ -- Release the SAT once we're done with it.
1907
+ img:RecycleSAT()
1908
+
1909
+ OutImage:Set(req, out)
1910
+ end
1911
+ '''
1912
+
1913
+
1914
+ # ─── Internal helpers ─────────────────────────────────────────────────────────
1915
+
1916
+ def _slider(varname: str, label: str, default: float = 0.0,
1917
+ min_: float = -1.0, max_: float = 1.0) -> str:
1918
+ return (
1919
+ f' {varname} = self:AddInput("{label}", "{label}", {{\n'
1920
+ f' LINKID_DataType = "Number",\n'
1921
+ f' INPID_InputControl = "SliderControl",\n'
1922
+ f' INP_Default = {default},\n'
1923
+ f' INP_MinScale = {min_},\n'
1924
+ f' INP_MaxScale = {max_},\n'
1925
+ f' }})\n'
1926
+ )
1927
+
1928
+
1929
+ def _checkbox(varname: str, label: str, default: int = 0) -> str:
1930
+ return (
1931
+ f' {varname} = self:AddInput("{label}", "{label}", {{\n'
1932
+ f' LINKID_DataType = "Number",\n'
1933
+ f' INPID_InputControl = "CheckboxControl",\n'
1934
+ f' INP_Integer = true,\n'
1935
+ f' INP_Default = {default},\n'
1936
+ f' }})\n'
1937
+ )
1938
+
1939
+
1940
+ # Public registry of generators.
1941
+ TEMPLATES = {
1942
+ # Color & per-pixel
1943
+ "color_matrix": color_matrix,
1944
+ "per_pixel": per_pixel,
1945
+ "channel_op": channel_op,
1946
+ # Geometric / spatial
1947
+ "transform": transform,
1948
+ "spatial_warp": spatial_warp,
1949
+ # Text & shapes
1950
+ "text_overlay": text_overlay,
1951
+ "shape_generator": shape_generator,
1952
+ # Source / temporal
1953
+ "source_generator": source_generator,
1954
+ "time_displace": time_displace,
1955
+ # Filters
1956
+ "builtin_blur": builtin_blur,
1957
+ "builtin_resize": builtin_resize,
1958
+ "variable_blur": variable_blur,
1959
+ # Modifiers
1960
+ "modifier": modifier,
1961
+ "point_modifier": point_modifier,
1962
+ # Display / shaders
1963
+ "view_lut": view_lut,
1964
+ "dctl_kernel": dctl_kernel,
1965
+ # Reference / educational
1966
+ "controls_demo": controls_demo,
1967
+ "notifychanged_demo": notifychanged_demo,
1968
+ }