├── .gitignore ├── FuAlign.py ├── README.md ├── fa_backend ├── __init__.py └── fusion_alias.py └── imgs ├── FuAlign_banner.png └── FuAlign_window.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | **/__pycache__ 3 | testing_comp/ 4 | logic.txt 5 | ui_design/ 6 | atom/ 7 | to_change.txt 8 | venv 9 | -------------------------------------------------------------------------------- /FuAlign.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | import tkinter as tk 4 | from typing import Callable 5 | 6 | # For testing inside VSCode. 7 | try: 8 | from fa_backend import Tool, Comp, Fusion 9 | 10 | try: 11 | fusion 12 | except NameError: 13 | print("Initializing fake Fusion.") 14 | fusion = Fusion() 15 | comp = Comp() 16 | 17 | except ModuleNotFoundError: 18 | pass 19 | 20 | # =========================================================================== # 21 | # ======================== BACKEND =================================== # 22 | # =========================================================================== # 23 | 24 | 25 | # Constant for DataWindow Error. 26 | NEG_MILL = -1_000_000 27 | EMPTY_DATA_WINDOW = {key + 1: NEG_MILL for key in range(4)} 28 | 29 | 30 | HISTORY: list[dict[Tool, dict[int, float]]] = [] 31 | 32 | 33 | @dataclass 34 | class Align: 35 | merge: Tool 36 | 37 | def __post_init__(self): 38 | self.fg_tool = self.get_tool() 39 | 40 | def get_tool(self): 41 | return self.merge.Foreground.GetConnectedOutput().GetTool() 42 | 43 | @property 44 | def tool_pixel_width(self): 45 | return self.fg_tool.GetAttrs("TOOLI_ImageWidth") 46 | 47 | @property 48 | def tool_pixel_height(self): 49 | return self.fg_tool.GetAttrs("TOOLI_ImageHeight") 50 | 51 | @property 52 | def tool_img_pixel_width(self) -> int: 53 | if not self.tool_data_window: 54 | return self.tool_pixel_width 55 | 56 | x0, y0, x1, y1 = self.tool_data_window.values() 57 | return x1 - x0 58 | 59 | @property 60 | def tool_img_pixel_height(self) -> int: 61 | if not self.tool_data_window: 62 | return self.tool_pixel_height 63 | 64 | x0, y0, x1, y1 = self.tool_data_window.values() 65 | return y1 - y0 66 | 67 | @property 68 | def tool_rel_width(self): 69 | return self.tool_img_pixel_width * self.merge_size / self.merge_pixel_width 70 | 71 | @property 72 | def tool_rel_height(self): 73 | return self.tool_img_pixel_height * self.merge_size / self.merge_pixel_height 74 | 75 | @property 76 | def merge_pixel_width(self) -> int: 77 | return self.merge.GetAttrs("TOOLI_ImageWidth") 78 | 79 | @property 80 | def merge_pixel_height(self) -> int: 81 | return self.merge.GetAttrs("TOOLI_ImageHeight") 82 | 83 | @property 84 | def merge_size(self) -> float: 85 | return self.merge.GetInput("Size") 86 | 87 | @property 88 | def tool_center_coords(self) -> tuple[float, float]: 89 | """Returns a normalized value of the tool's 'Center' position relative to itself. 90 | If its image information is only a fraction of its width, this method will return 91 | where in the screen this information is centered. Else, we get the default 0.5, 0.5 values. 92 | """ 93 | if self.tool_data_window: 94 | x0, y0, x1, y1 = self.tool_data_window.values() 95 | else: 96 | print( 97 | f"It looks like {self.fg_tool.GetAttrs('TOOLS_Name')} is off screen. Alignment might not work properly." 98 | ) 99 | return 0.5, 0.5 100 | 101 | x = self.tool_img_pixel_width / 2 + x0 102 | y = self.tool_img_pixel_height / 2 + y0 103 | 104 | # Normalized... 105 | x = x / self.tool_pixel_width 106 | y = y / self.tool_pixel_height 107 | 108 | return x, y 109 | 110 | @property 111 | def tool_offset_in_self(self) -> tuple[float, float]: 112 | """Returns the amount of offset from center of a tool's image rectangle.""" 113 | x_offset, y_offset = [pos - 0.5 for pos in self.tool_center_coords] 114 | 115 | return x_offset, y_offset 116 | 117 | @property 118 | def tool_offset_in_merge(self) -> tuple[float, float]: 119 | """Returns the amount of offset from center of a tool when it is first placed on the Merge.""" 120 | 121 | x_offset, y_offset = [ 122 | offset * rel_res 123 | for offset, rel_res in zip( 124 | self.tool_offset_in_self, self.tool_rel_resolution 125 | ) 126 | ] 127 | 128 | return x_offset, y_offset 129 | 130 | @property 131 | def tool_rel_resolution(self) -> tuple[float, float]: 132 | 133 | rel_x = self.tool_pixel_width / self.merge_pixel_width 134 | rel_y = self.tool_pixel_height / self.merge_pixel_height 135 | 136 | return rel_x, rel_y 137 | 138 | @property 139 | def tool_data_window(self) -> dict[int, int] | None: 140 | dw = self.fg_tool.Output.GetValue().DataWindow 141 | if dw == EMPTY_DATA_WINDOW: 142 | return None 143 | if not dw: 144 | return None 145 | return dw 146 | 147 | @property 148 | def merge_current_x(self): 149 | return self.merge.GetInput("Center")[1] 150 | 151 | @property 152 | def merge_current_y(self): 153 | return self.merge.GetInput("Center")[2] 154 | 155 | @property 156 | def edges_and_centers(self) -> dict[str, float]: 157 | """Returns normalized edge positions (top, left, bottom, right) for tool in merge""" 158 | edges_and_centers = {} 159 | x_offset, y_offset = self.tool_offset_in_merge 160 | x = x_offset + self.merge_current_x 161 | y = y_offset + self.merge_current_y 162 | 163 | edges_and_centers["top"] = y + self.tool_rel_height / 2 164 | edges_and_centers["left"] = x - self.tool_rel_width / 2 165 | edges_and_centers["bottom"] = y - self.tool_rel_height / 2 166 | edges_and_centers["right"] = x + self.tool_rel_width / 2 167 | 168 | edges_and_centers["horizontal"] = x 169 | edges_and_centers["vertical"] = y 170 | 171 | return edges_and_centers 172 | 173 | 174 | def get_merges(comp: Comp) -> list[Align] | None: 175 | merges = comp.GetToolList(True, "Merge").values() 176 | if not merges: 177 | return None 178 | merges_and_tools = [Align(merge) for merge in merges] 179 | return merges_and_tools 180 | 181 | 182 | # Align edges funcs 183 | def align_left_edges(object: Align, edge: float) -> None: 184 | x = edge + object.tool_rel_width / 2 - object.tool_offset_in_merge[0] 185 | y = object.merge_current_y 186 | 187 | object.merge.SetInput("Center", [x, y]) 188 | 189 | 190 | def align_right_edges(object: Align, edge: float) -> None: 191 | x = edge - object.tool_rel_width / 2 - object.tool_offset_in_merge[0] 192 | y = object.merge_current_y 193 | 194 | object.merge.SetInput("Center", [x, y]) 195 | 196 | 197 | def align_top_edges(object: Align, edge: float) -> None: 198 | x = object.merge_current_x 199 | y = edge - object.tool_rel_height / 2 - object.tool_offset_in_merge[1] 200 | 201 | object.merge.SetInput("Center", [x, y]) 202 | 203 | 204 | def align_bottom_edges(object: Align, edge: float) -> None: 205 | x = object.merge_current_x 206 | y = edge + object.tool_rel_height / 2 - object.tool_offset_in_merge[1] 207 | 208 | object.merge.SetInput("Center", [x, y]) 209 | 210 | 211 | # Align centers funcs 212 | def align_horizontal_centers(object: Align, center: float) -> None: 213 | x = center - object.tool_offset_in_merge[0] 214 | y = object.merge_current_y 215 | 216 | object.merge.SetInput("Center", [x, y]) 217 | 218 | 219 | def align_vertical_centers(object: Align, center: float) -> None: 220 | x = object.merge_current_x 221 | y = center - object.tool_offset_in_merge[1] 222 | 223 | object.merge.SetInput("Center", [x, y]) 224 | 225 | 226 | # Distribute funcs 227 | def distribute_horizontally(align_objects: list[Align]) -> None: 228 | global comp 229 | 230 | def by_edge(object: Align): 231 | return object.edges_and_centers["left"] 232 | 233 | align_objects.sort(key=by_edge) 234 | 235 | left_edge = min([obj.edges_and_centers["left"] for obj in align_objects]) 236 | right_edge = max([obj.edges_and_centers["right"] for obj in align_objects]) 237 | canvas_width = right_edge - left_edge 238 | 239 | total_tool_width = sum([obj.tool_rel_width for obj in align_objects]) 240 | gutter = (canvas_width - total_tool_width) / (len(align_objects) - 1) 241 | 242 | comp.Lock() 243 | for idx, object in enumerate(align_objects): 244 | if idx == 0 or idx == len(align_objects) - 1: 245 | edge = object.edges_and_centers["right"] + gutter 246 | continue 247 | else: 248 | align_left_edges(object, edge) 249 | edge = object.edges_and_centers["right"] + gutter 250 | comp.Unlock() 251 | 252 | 253 | def distribute_vertically(align_objects: list[Align]) -> None: 254 | global comp 255 | 256 | def by_edge(object: Align): 257 | return object.edges_and_centers["bottom"] 258 | 259 | align_objects.sort(key=by_edge) 260 | 261 | bottom_edge = min([obj.edges_and_centers["bottom"] for obj in align_objects]) 262 | top_edge = max([obj.edges_and_centers["top"] for obj in align_objects]) 263 | canvas_height = top_edge - bottom_edge 264 | 265 | total_tool_height = sum([obj.tool_rel_height for obj in align_objects]) 266 | gutter = (canvas_height - total_tool_height) / (len(align_objects) - 1) 267 | 268 | comp.Lock() 269 | for idx, object in enumerate(align_objects): 270 | if idx == 0 or idx == len(align_objects) - 1: 271 | edge = object.edges_and_centers["top"] + gutter 272 | continue 273 | else: 274 | align_bottom_edges(object, edge) 275 | edge = object.edges_and_centers["top"] + gutter 276 | comp.Unlock() 277 | 278 | 279 | @dataclass 280 | class Operation: 281 | id: str 282 | full_name: str 283 | group: str 284 | keyboard_shortcut: str 285 | icon_dims: list[tuple] 286 | align_func: Callable 287 | edge_func: Callable = None 288 | parsed_shortcuts: list[str] = None 289 | 290 | def execute(self) -> None: 291 | global comp 292 | align_objects = get_merges(comp) 293 | 294 | if not align_objects: 295 | print("No merges selected.") 296 | return 297 | 298 | # for distribute functions 299 | if not self.edge_func: 300 | self.align_func(align_objects) 301 | return 302 | 303 | edges_or_centers = [ 304 | object.edges_and_centers[self.id] for object in align_objects 305 | ] 306 | 307 | edge_or_center = self.edge_func(edges_or_centers) 308 | 309 | comp.Lock() 310 | comp.StartUndo(f"FuAlign: {self.full_name}") 311 | 312 | for obj in align_objects: 313 | self.align_func(obj, edge_or_center) 314 | 315 | comp.EndUndo(True) 316 | comp.Unlock() 317 | 318 | return 319 | 320 | 321 | # CREATING OPERATIONS ================================================== 322 | GROUPS = ["align edges", "align centers", "distribute"] 323 | 324 | OPERATIONS: dict[str, Operation] = {} 325 | 326 | OPERATIONS["top"] = Operation( 327 | id="top", 328 | full_name="Align top edges", 329 | group=GROUPS[0], 330 | keyboard_shortcut="T", 331 | icon_dims=[ 332 | (1, 0.1, 0, 0.05), # line 333 | (0.3, 0.7, 0.15, 0.25), # rect1 334 | (0.3, 0.5, 0.55, 0.25), # rect 2 335 | ], 336 | align_func=align_top_edges, 337 | edge_func=max, 338 | ) 339 | 340 | OPERATIONS["bottom"] = Operation( 341 | id="bottom", 342 | full_name="Align bottom edges", 343 | group=GROUPS[0], 344 | keyboard_shortcut="B", 345 | icon_dims=[ 346 | (1, 0.1, 0, 0.85), # line 347 | (0.3, 0.7, 0.15, 0.05), # rect1 348 | (0.3, 0.5, 0.55, 0.25), # rect2 349 | ], 350 | align_func=align_bottom_edges, 351 | edge_func=min, 352 | ) 353 | 354 | OPERATIONS["left"] = Operation( 355 | id="left", 356 | full_name="Align left edges", 357 | group=GROUPS[0], 358 | keyboard_shortcut="L", 359 | icon_dims=[ 360 | (0.1, 1, 0.05, 0), # line 361 | (0.7, 0.3, 0.25, 0.15), # rect1 362 | (0.5, 0.3, 0.25, 0.55), # rect 2 363 | ], 364 | align_func=align_left_edges, 365 | edge_func=min, 366 | ) 367 | 368 | OPERATIONS["right"] = Operation( 369 | id="right", 370 | full_name="Align right edges", 371 | group=GROUPS[0], 372 | keyboard_shortcut="R", 373 | icon_dims=[ 374 | (0.1, 1, 0.85, 0), # line 375 | (0.7, 0.3, 0.05, 0.15), # rect1 376 | (0.5, 0.3, 0.25, 0.55), # rect2 377 | ], 378 | align_func=align_right_edges, 379 | edge_func=max, 380 | ) 381 | 382 | OPERATIONS["horizontal"] = Operation( 383 | id="horizontal", 384 | full_name="Align horizontal centers", 385 | group=GROUPS[1], 386 | keyboard_shortcut="H", 387 | icon_dims=[ 388 | (0.1, 1, 0.45, 0), # line 389 | (0.5, 0.3, 0.25, 0.15), # rect1 390 | (0.7, 0.3, 0.15, 0.55), # rect2 391 | ], 392 | align_func=align_horizontal_centers, 393 | edge_func=lambda x: (max(x) - min(x)) / 2 + min(x), 394 | ) 395 | 396 | OPERATIONS["vertical"] = Operation( 397 | id="vertical", 398 | full_name="Align vertical centers", 399 | group=GROUPS[1], 400 | keyboard_shortcut="V", 401 | icon_dims=[ 402 | (1, 0.1, 0, 0.45), # line 403 | (0.3, 0.5, 0.15, 0.25), # rect1 404 | (0.3, 0.7, 0.55, 0.15), # rect2 405 | ], 406 | align_func=align_vertical_centers, 407 | edge_func=lambda x: (max(x) - min(x)) / 2 + min(x), 408 | ) 409 | 410 | OPERATIONS["horizontally"] = Operation( 411 | id="horizontally", 412 | full_name="Distribute horizontally", 413 | group=GROUPS[2], 414 | keyboard_shortcut="⇧H", 415 | icon_dims=[ 416 | (0.1, 1, 0.1, 0), # line1 417 | (0.3, 0.6, 0.35, 0.2), # rect1 418 | (0.1, 1, 0.8, 0), # line2 419 | ], 420 | align_func=distribute_horizontally, 421 | ) 422 | 423 | OPERATIONS["vertically"] = Operation( 424 | id="vertically", 425 | full_name="Distribute vertically", 426 | group=GROUPS[2], 427 | keyboard_shortcut="⇧V", 428 | icon_dims=[ 429 | (1, 0.1, 0, 0.1), # line1 430 | (0.6, 0.3, 0.2, 0.35), # rect1 431 | (1, 0.1, 0, 0.8), # line2 432 | ], 433 | align_func=distribute_vertically, 434 | ) 435 | 436 | 437 | # ########################################################################### # 438 | 439 | # =========================================================================== # 440 | # ======================== FRONTEND =================================== # 441 | # =========================================================================== # 442 | class Color: 443 | HOVER = "#c1c1c1" 444 | NORMAL = "#868686" 445 | ACTIVE = "#f1f1f1" 446 | CANVAS_BG = "#252525" 447 | TEXT = "#A3A3A3" 448 | 449 | 450 | class Font: 451 | DEFAULT = "TkTextFont" 452 | TOOLTIP_BOLD = "TkTooltipFont 12 bold" 453 | TOOLTIP = "TkTooltipFont 12" 454 | 455 | 456 | @dataclass 457 | class UIRow: 458 | parent: tk.Widget 459 | frame: tk.Frame 460 | name: str 461 | 462 | def __post_init__(self): 463 | self.title_var = tk.StringVar() 464 | self.title = tk.Label( 465 | self.frame, 466 | textvariable=self.title_var, 467 | width=20, 468 | height=1 if self.name in ["header", "footer"] else 2, 469 | anchor=tk.SW, 470 | padx=5, 471 | pady=0 if self.name in ["header", "footer"] else 5, 472 | ) 473 | self.title.grid(column=1, columnspan=4, row=1, sticky=tk.W) 474 | 475 | def describe(self, name: str): 476 | if not name: 477 | self.title_var.set(self.name.capitalize()) 478 | return 479 | self.title_var.set(name) 480 | 481 | 482 | @dataclass 483 | class UIElement: 484 | parent: UIRow 485 | name: str 486 | row: int 487 | col: int 488 | operation: Operation 489 | key: str 490 | canvas: tk.Canvas = None 491 | icon_dims: list[tuple] = None 492 | 493 | def describe(self, event): 494 | self.parent.describe(self.full_name) 495 | 496 | def undescribe(self, event): 497 | self.parent.describe("") 498 | 499 | @property 500 | def full_name(self): 501 | return f"{self.operation.full_name} [{self.key}]" 502 | 503 | # Creates rectangle from normalized dimensions instead of absolute coordinates. 504 | def draw_rect( 505 | self, 506 | width: float, 507 | height: float, 508 | x: float, 509 | y: float, 510 | **configs, 511 | ) -> int: 512 | 513 | multiplier = self.canvas.winfo_width() 514 | 515 | x0 = x * multiplier 516 | x1 = (x + width) * multiplier 517 | y0 = y * multiplier 518 | y1 = (y + height) * multiplier 519 | 520 | return self.canvas.create_rectangle(x0, y0, x1, y1, **configs) 521 | 522 | # Creates all rectangles necessary to draw each icon 523 | def draw_icon(self, **config) -> None: 524 | for dim in self.icon_dims: 525 | self.draw_rect(*dim, **config) 526 | set_hover_style(self.canvas) 527 | 528 | 529 | # Button style event handlers ====================================== 530 | def on_hover(event: tk.Event): 531 | canvas: tk.Canvas = event.widget 532 | canvas.itemconfig("icon", fill=Color.HOVER) 533 | 534 | 535 | def on_leave(event: tk.Event): 536 | canvas: tk.Canvas = event.widget 537 | canvas.itemconfig("icon", fill=Color.NORMAL) 538 | 539 | 540 | def on_click(event: tk.Event): 541 | canvas: tk.Canvas = event.widget 542 | canvas.itemconfig("icon", fill=Color.ACTIVE) 543 | 544 | 545 | def set_hover_style(canvas: tk.Canvas): 546 | canvas.bind("", on_hover) 547 | canvas.bind("", on_leave) 548 | canvas.bind("", on_click) 549 | canvas.bind("", on_hover) 550 | 551 | 552 | # PARSE KEYBOARD SHORTCUTS 553 | def parse_key(key: str) -> list[str]: 554 | if "⇧" in key: 555 | modifier = "Shift" 556 | key = key[-1] 557 | upper_and_lower = False 558 | else: 559 | modifier = "Key" 560 | upper_and_lower = True 561 | 562 | if not upper_and_lower: 563 | return [f"<{modifier}-{key}>"] 564 | return [f"<{modifier}-{key}>", f"<{modifier}-{key.lower()}>"] 565 | 566 | 567 | for operation in OPERATIONS.values(): 568 | shortcut = operation.keyboard_shortcut 569 | operation.parsed_shortcuts = parse_key(shortcut) 570 | 571 | 572 | class App: 573 | def build(self): 574 | root = tk.Tk() 575 | self.root = root 576 | 577 | # Appearance. 578 | root.configure(background=Color.CANVAS_BG) 579 | root.title("FuAlign") 580 | root.resizable(False, False) 581 | root.attributes("-topmost", True) 582 | root.option_add("*background", Color.CANVAS_BG) 583 | root.option_add("*foreground", Color.TEXT) 584 | 585 | # Setting up layout. 586 | root.rowconfigure(index=1, weight=0) # HEADER ROW 587 | 588 | for idx in range(len(GROUPS)): 589 | root.rowconfigure(index=idx + 2, weight=1) 590 | 591 | root.rowconfigure(index=idx + 2, weight=0) # FOOTER ROW 592 | 593 | # Creating UIRows. 594 | header = tk.Frame(pady=10, height=10) 595 | footer = tk.Label(pady=10, height=1) 596 | 597 | self.ui_rows: dict[str, UIRow] = {} 598 | for group_name in GROUPS: 599 | ui_row = UIRow(self.root, tk.Frame(padx=10), group_name) 600 | self.ui_rows[group_name] = ui_row 601 | ui_row.title_var.set(group_name.capitalize()) 602 | 603 | # Gridding rows 604 | header.grid(row=1) 605 | 606 | for idx, row in enumerate(self.ui_rows.values()): 607 | row.frame.grid(row=idx + 2) 608 | 609 | footer.grid(row=idx + 3) 610 | 611 | # Configuring row grids. 612 | for row in self.ui_rows.values(): 613 | row.frame.rowconfigure(index=1, weight=0) 614 | row.frame.rowconfigure(index=2, weight=1) 615 | 616 | row.frame.columnconfigure(index=1, weight=0) 617 | row.frame.columnconfigure(index=2, weight=0) 618 | row.frame.columnconfigure(index=3, weight=0) 619 | row.frame.columnconfigure(index=4, weight=1) 620 | 621 | # Creating UIElements. 622 | self.ui_elements: dict[str, UIElement] = {} 623 | 624 | for group in GROUPS: 625 | operations = [op for op in OPERATIONS.values() if op.group == group] 626 | for idx, operation in enumerate(operations): 627 | ui_element = UIElement( 628 | parent=self.ui_rows[operation.group], 629 | name=operation.id, 630 | row=2, 631 | col=idx + 1, 632 | operation=operation, 633 | key=operation.keyboard_shortcut, 634 | ) 635 | ui_element.icon_dims = operation.icon_dims 636 | self.ui_elements[ui_element.name] = ui_element 637 | 638 | # Creating Canvases for the icons 639 | for el in self.ui_elements.values(): 640 | el.canvas = tk.Canvas( 641 | el.parent.frame, 642 | width=20, 643 | height=20, 644 | background=Color.CANVAS_BG, 645 | highlightthickness=0, 646 | relief="ridge", 647 | bd=0, 648 | ) 649 | el.canvas.grid(row=el.row, column=el.col, sticky=tk.W, padx=10, pady=5) 650 | 651 | # Updates canvas before drawing icons. 652 | for el in self.ui_elements.values(): 653 | el.canvas.update() 654 | 655 | # Draws icons. 656 | for el in self.ui_elements.values(): 657 | el.draw_icon(fill=Color.NORMAL, outline=Color.CANVAS_BG, tag="icon") 658 | 659 | # Binds canvases. 660 | for el in self.ui_elements.values(): 661 | el.canvas.bind("", el.describe, add="+") 662 | el.canvas.bind("", el.undescribe, add="+") 663 | el.canvas.bind( 664 | "", 665 | lambda e, name=el.name: OPERATIONS[name].execute(), 666 | add="+", 667 | ) 668 | 669 | # Binds root for keyboard shortcuts 670 | for op in OPERATIONS.values(): 671 | name, shortcuts = op.id, op.parsed_shortcuts 672 | for shortcut in shortcuts: 673 | root.bind(shortcut, lambda e, name=name: OPERATIONS[name].execute()) 674 | 675 | # Window size and position 676 | window_width = root.winfo_width() 677 | window_height = root.winfo_height() 678 | 679 | # get the screen dimension 680 | screen_width = root.winfo_screenwidth() 681 | screen_height = root.winfo_screenheight() 682 | 683 | # find the center point 684 | x = int((screen_width / 2 - window_width / 2)) 685 | y = int((screen_height / 2 - window_height / 2)) 686 | 687 | root.geometry(f"{window_width}x{window_height}+{x}+{y}") 688 | 689 | def run(self): 690 | self.build() 691 | 692 | self.root.mainloop() 693 | 694 | 695 | if __name__ == "__main__": 696 | app = App() 697 | app.run() 698 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | !["FuAlign – Comp Script"](https://github.com/brunocbreis/FuAlign/blob/master/imgs/FuAlign_banner.png) 2 | 3 | 4 | ![version: 0.1](https://img.shields.io/badge/version-0.1-blue) ![works with DaVinci Resolve 18+](https://img.shields.io/badge/DaVinci%20Resolve-18+-lightgrey) ![works with Fusion Studio 18+](https://img.shields.io/badge/Fusion%20Studio-18+-lightgrey) ![requires Python 3.10](https://img.shields.io/badge/python-%203.10-lightgreen) [!["Buy Me A Coffee"](https://img.shields.io/badge/-buy_me_a%C2%A0coffee-gray?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/brunoreis) 5 | 6 | ## It's just like in Photoshop. ☁️ 7 | Ever wish Fusion had those pesky little buttons that help you align stuff on screen with a single click, just like in Adobe CC apps? 8 | 9 | Well, it does now. 😎 10 | 11 | ## How it works 📏 12 | It's as simple as running the FuAlign comp script, selecting the Merges you want to align or distribute uniformly, and press a button. It even supports keyboard shortcuts for mouseless operation. ⌨️ 13 | !["FuAlign user interface"](https://github.com/brunocbreis/FuAlign/blob/master/imgs/FuAlign_window.png) 14 | 15 | ## How to install ⚙️ 16 | First of all, make sure you have the [latest version of Python](https://www.python.org/downloads/) installed in your system. 🐍 17 | 18 | Then, download [this file](https://github.com/brunocbreis/FuAlign/releases/download/v0.2/FuAlign.py) and save it anywhere you'd like. To run the app, just drag it into Fusion any time you're working on a comp. 19 | 20 | If you prefer to run it directly from Fusion's or DaVinci Resolve's menu, these are the folders you should save it into: 21 | 22 |
23 | 🪟 Windows 24 | 25 | DaVinci Resolve: `C:\ProgramData\Blackmagic Design\DaVinci Resolve\Fusion\Scripts\Comp` 26 | 27 | Fusion Studio: `C:\ProgramData\Blackmagic Design\Fusion Studio\Scripts\Comp` 28 |
29 | 30 |
31 | 💻 MacOS 32 | 33 | DaVinci Resolve: `[user]/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp` 34 | 35 | Fusion Studio: `[user]/Library/Application Support/Blackmagic Design/Fusion Studio/Scripts/Comp` 36 |
37 | 38 | Then, from **DaVinci Resolve**, while in the Fusion Page, you can go to `Workspace > Scripts > Comp` and run the *FuAlign* script from there. In **Fusion Studio**, the path is `Scripts > Comp`. 39 | -------------------------------------------------------------------------------- /fa_backend/__init__.py: -------------------------------------------------------------------------------- 1 | from .fusion_alias import * 2 | -------------------------------------------------------------------------------- /fa_backend/fusion_alias.py: -------------------------------------------------------------------------------- 1 | # resolve 2 | from __future__ import annotations 3 | from dataclasses import dataclass, field 4 | from typing import Any 5 | 6 | 7 | class Resolve: 8 | def __init__(self) -> None: 9 | print("Got Resolve.") 10 | 11 | 12 | # fusion methods 13 | class Fusion: 14 | def GetResolve(self) -> Resolve: 15 | return Resolve() 16 | 17 | 18 | # tool methods 19 | class Tool: 20 | def __init__(self, id: str) -> None: 21 | self.id = id 22 | self._inputs = {} 23 | self._attrs = {} 24 | self._output: Output = None 25 | 26 | def __str__(self) -> str: 27 | try: 28 | name = self._attrs["TOOLS_Name"] 29 | except KeyError: 30 | name = f"instance of {self.id}" 31 | return name 32 | 33 | def SetAttrs(self, attrs: dict[str, str]) -> None: 34 | for key, value in attrs.items(): 35 | self._attrs[key] = value 36 | print(f"Setting {key} to {value}") 37 | 38 | def GetAttrs(self, attr: str) -> Any: 39 | return self._attrs[attr] 40 | 41 | def SetInput(self, input_name: str, value: float | str | int) -> None: 42 | self._inputs[input_name] = value 43 | print(f"Setting {self} {input_name} to {value}") 44 | 45 | def GetInput(self, input_name: str) -> float | int | str: 46 | return self._inputs[input_name] 47 | 48 | def Delete(self) -> None: 49 | print(f"Deleting {self}") 50 | 51 | @property 52 | def Output(self) -> Output: 53 | return self._output 54 | 55 | @property 56 | def Foreground(self) -> Input: 57 | return self._inputs["Foreground"] 58 | 59 | 60 | @dataclass 61 | class Input: 62 | _connected_output: Output 63 | 64 | def GetConnectedOutput(self) -> Output: 65 | return self._connected_output 66 | 67 | 68 | @dataclass 69 | class Image: 70 | _datawindow: dict[int, int] 71 | 72 | @property 73 | def DataWindow(self) -> dict[int, int] | None: 74 | return self._datawindow 75 | 76 | 77 | @dataclass 78 | class Output: 79 | _image: Image 80 | _tool: Tool 81 | 82 | def GetValue(self, input_name: str = "", time: int = 0) -> Image: 83 | return self._image 84 | 85 | def GetTool(self) -> Tool: 86 | return self._tool 87 | 88 | 89 | # comp methods 90 | @dataclass 91 | class Comp: 92 | _list_of_tools: dict[int, Tool] = field(init=False, default_factory=dict) 93 | 94 | def AddTool(self, tool_id: str, x: int, y: int) -> Tool: 95 | return Tool(tool_id) 96 | 97 | @property 98 | def CurrentFrame(self): 99 | return CurrentFrame() 100 | 101 | def GetToolList(self, selected: bool, type: str = "") -> dict[int, Tool]: 102 | return self._list_of_tools 103 | 104 | def Lock(self): 105 | pass 106 | 107 | def Unlock(self): 108 | pass 109 | 110 | 111 | class Flow: 112 | def QueueSetPos(self, tool: Tool, x: int, y: int) -> None: 113 | print(f"Queuing {tool}'s position to be set to ({x}, {y}).") 114 | 115 | def FlushSetPosQueue(self) -> None: 116 | print("Flushing Set Pos Queue.\n") 117 | 118 | def SetPos(self, tool: Tool, x: int, y: int) -> None: 119 | print(f"Setting {tool}'s position to ({x}, {y}).") 120 | 121 | 122 | class CurrentFrame: 123 | @property 124 | def FlowView(self) -> Flow: 125 | return Flow() 126 | 127 | def ViewOn(self, tool: Tool, view: int) -> None: 128 | print(f"Viewing {tool} on monitor number {view}") 129 | -------------------------------------------------------------------------------- /imgs/FuAlign_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunocbreis/FuAlign/254d3ef8ecdc27f5291a762ede01fb3cda08cdc9/imgs/FuAlign_banner.png -------------------------------------------------------------------------------- /imgs/FuAlign_window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brunocbreis/FuAlign/254d3ef8ecdc27f5291a762ede01fb3cda08cdc9/imgs/FuAlign_window.png --------------------------------------------------------------------------------