|
30 | 30 | logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
31 | 31 |
|
32 | 32 |
|
33 | | -DEFAULT_SOURCE = Path("../../frontend/plugin/clipabit.py") |
34 | | -DEFAULT_COPY_NAME = "clipabit.py" |
| 33 | +DEFAULT_SOURCE = Path("plugin") |
| 34 | +DEFAULT_SHIM_NAME = "ClipABit.py" |
| 35 | +DEFAULT_PACKAGE_NAME = "clipabit" |
35 | 36 |
|
36 | 37 |
|
37 | | -def get_resolve_script_dir(): |
38 | | - """ |
39 | | - Returns the Path object for the DaVinci Resolve Utility Scripts directory |
40 | | - based on the current operating system. |
41 | | - """ |
| 38 | +def get_resolve_fusion_dir(): |
| 39 | + """Returns the base Fusion path.""" |
42 | 40 | home = Path.home() |
43 | 41 | system = platform.system() |
44 | 42 |
|
45 | 43 | if system == "Windows": |
46 | | - # Windows: %APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Fusion\Scripts\Utility |
47 | | - # We use os.environ for APPDATA to be safer than hardcoding 'AppData/Roaming' |
48 | 44 | base_path = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming")) |
49 | | - return base_path / "Blackmagic Design" / "DaVinci Resolve" / "Support" / "Fusion" / "Scripts" / "Utility" |
50 | | - |
51 | | - elif system == "Darwin": # macOS |
52 | | - # Mac: ~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility |
53 | | - return home / "Library" / "Application Support" / "Blackmagic Design" / "DaVinci Resolve" / "Fusion" / "Scripts" / "Utility" |
54 | | - |
55 | | - else: # Linux |
56 | | - # Linux: ~/.local/share/DaVinci Resolve/Fusion/Scripts/Utility |
57 | | - # (Standard installation path for user-specific scripts) |
58 | | - return home / ".local" / "share" / "DaVinci Resolve" / "Fusion" / "Scripts" / "Utility" |
59 | | - |
60 | | - |
61 | | -def copy_file(src: Path, dst_dir: Path, dst_name: str = None) -> None: |
62 | | - dst_dir = dst_dir.expanduser() |
63 | | - if dst_name is None: |
64 | | - dst_name = src.name |
65 | | - |
66 | | - dst_dir.mkdir(parents=True, exist_ok=True) |
67 | | - dst = dst_dir / dst_name |
68 | | - |
69 | | - try: |
70 | | - shutil.copy2(str(src), str(dst)) |
71 | | - logger.info("Copied %s -> %s", src, dst) |
72 | | - except Exception as e: |
73 | | - logger.error("Failed to copy %s -> %s: %s", src, dst, e) |
74 | | - |
75 | | - |
76 | | -def run_polling(src: Path, dst_dir: Path, dst_name: str = None, interval: float = 0.5): |
77 | | - """Fallback polling loop if watchdog is not installed.""" |
78 | | - if not src.exists(): |
79 | | - logger.warning("Source file does not exist yet: %s", src) |
80 | | - |
| 45 | + return base_path / "Blackmagic Design" / "DaVinci Resolve" / "Support" / "Fusion" |
| 46 | + elif system == "Darwin": |
| 47 | + return home / "Library" / "Application Support" / "Blackmagic Design" / "DaVinci Resolve" / "Fusion" |
| 48 | + else: |
| 49 | + return home / ".local" / "share" / "DaVinci Resolve" / "Fusion" |
| 50 | + |
| 51 | + |
| 52 | +def get_resolve_paths(): |
| 53 | + """Returns dictionary of relevant Resolve paths.""" |
| 54 | + fusion = get_resolve_fusion_dir() |
| 55 | + return { |
| 56 | + "fusion": fusion, |
| 57 | + "utility": fusion / "Scripts" / "Utility", |
| 58 | + "modules": fusion / "Modules" |
| 59 | + } |
| 60 | + |
| 61 | + |
| 62 | +def sync_plugin(src_root: Path, paths: dict): |
| 63 | + """Sync all plugin parts to Resolve.""" |
| 64 | + # 1. Sync Shim |
| 65 | + shim_src = src_root / "clipabitshim.py" |
| 66 | + if shim_src.exists(): |
| 67 | + dst_utility = paths["utility"] |
| 68 | + dst_utility.mkdir(parents=True, exist_ok=True) |
| 69 | + shutil.copy2(shim_src, dst_utility / DEFAULT_SHIM_NAME) |
| 70 | + logger.info("Synced Shim: %s -> %s", shim_src.name, dst_utility / DEFAULT_SHIM_NAME) |
| 71 | + |
| 72 | + # 2. Sync Package |
| 73 | + pkg_src = src_root / "clipabit" |
| 74 | + if pkg_src.exists(): |
| 75 | + dst_modules_parent = paths["modules"] |
| 76 | + dst_modules_parent.mkdir(parents=True, exist_ok=True) |
| 77 | + dst_modules = dst_modules_parent / "clipabit" |
| 78 | + if dst_modules.exists(): |
| 79 | + shutil.rmtree(dst_modules) |
| 80 | + shutil.copytree(pkg_src, dst_modules) |
| 81 | + logger.info("Synced Package: %s -> %s", pkg_src.name, dst_modules) |
| 82 | + |
| 83 | + |
| 84 | +def run_polling(src: Path, paths: dict, interval: float = 0.5): |
| 85 | + """Fallback polling loop.""" |
| 86 | + logger.info("Polling started for %s", src) |
81 | 87 | last_mtime = None |
82 | | - try: |
83 | | - while True: |
84 | | - try: |
85 | | - mtime = src.stat().st_mtime if src.exists() else None |
86 | | - except Exception: |
87 | | - mtime = None |
88 | | - |
89 | | - if mtime is not None and mtime != last_mtime: |
| 88 | + while True: |
| 89 | + try: |
| 90 | + # Check for any change in the whole plugin tree |
| 91 | + mtime = 0 |
| 92 | + for p in src.rglob("*"): |
| 93 | + if p.is_file(): |
| 94 | + mtime = max(mtime, p.stat().st_mtime) |
| 95 | + |
| 96 | + if mtime > (last_mtime or 0): |
90 | 97 | last_mtime = mtime |
91 | | - copy_file(src, dst_dir, dst_name) |
92 | | - |
93 | | - time.sleep(interval) |
94 | | - except KeyboardInterrupt: |
95 | | - logger.info("Polling watcher stopped by user") |
| 98 | + sync_plugin(src, paths) |
| 99 | + except Exception as e: |
| 100 | + logger.error("Polling error: %s", e) |
| 101 | + time.sleep(interval) |
96 | 102 |
|
97 | 103 |
|
98 | | -def run_watchdog(src: Path, dst_dir: Path, dst_name: str = None): |
99 | | - """Use watchdog to observe the file and copy on modifications.""" |
| 104 | +def run_watchdog(src: Path, paths: dict): |
| 105 | + """Use watchdog to observe the directory.""" |
100 | 106 | try: |
101 | 107 | from watchdog.observers import Observer |
102 | 108 | from watchdog.events import FileSystemEventHandler |
103 | | - except Exception as e: |
104 | | - logger.warning("watchdog not available (%s), falling back to polling", e) |
105 | | - return run_polling(src, dst_dir, dst_name) |
| 109 | + except ImportError: |
| 110 | + return run_polling(src, paths) |
106 | 111 |
|
107 | 112 | class Handler(FileSystemEventHandler): |
108 | | - def __init__(self, src_path: Path, dst_dir: Path, dst_name: str | None): |
109 | | - super().__init__() |
110 | | - self.src_path = src_path.resolve() |
111 | | - self.dst_dir = dst_dir |
112 | | - self.dst_name = dst_name |
113 | | - self._last_copied = 0.0 |
114 | | - |
115 | | - def on_modified(self, event): |
116 | | - try: |
117 | | - event_path = Path(event.src_path).resolve() |
118 | | - except Exception: |
119 | | - return |
| 113 | + def __init__(self, src_root: Path, paths: dict): |
| 114 | + self.src_root = src_root.resolve() |
| 115 | + self.paths = paths |
| 116 | + self._last_sync = 0.0 |
120 | 117 |
|
121 | | - # Debounce quick successive events (editors often trigger multiple events) |
| 118 | + def on_any_event(self, event): |
| 119 | + if event.is_directory: |
| 120 | + return |
| 121 | + |
| 122 | + # Debounce |
122 | 123 | now = time.time() |
123 | | - if (now - self._last_copied) < 0.2: |
| 124 | + if (now - self._last_sync) < 0.5: |
124 | 125 | return |
125 | | - |
126 | | - if event_path == self.src_path: |
127 | | - self._last_copied = now |
128 | | - copy_file(self.src_path, self.dst_dir, self.dst_name) |
129 | | - |
130 | | - # Some editors replace files (moved/created) — copy on created too |
131 | | - def on_created(self, event): |
| 126 | + |
| 127 | + # Check if it's inside our plugin dir |
132 | 128 | try: |
133 | 129 | event_path = Path(event.src_path).resolve() |
| 130 | + if self.src_root in event_path.parents or event_path == self.src_root: |
| 131 | + self._last_sync = now |
| 132 | + sync_plugin(self.src_root, self.paths) |
134 | 133 | except Exception: |
135 | | - return |
136 | | - |
137 | | - if event_path == self.src_path: |
138 | | - copy_file(self.src_path, self.dst_dir, self.dst_name) |
| 134 | + pass |
139 | 135 |
|
140 | | - handler = Handler(src.resolve(), dst_dir, dst_name) |
| 136 | + handler = Handler(src, paths) |
141 | 137 | observer = Observer() |
142 | | - |
143 | | - # Watch the parent directory of the source file |
144 | | - watch_dir = str(src.resolve().parent) |
145 | | - observer.schedule(handler, watch_dir, recursive=False) |
| 138 | + observer.schedule(handler, str(src.resolve()), recursive=True) |
146 | 139 | observer.start() |
147 | 140 | logger.info("Started watchdog observer for %s", src) |
148 | 141 |
|
149 | 142 | try: |
150 | 143 | while True: |
151 | 144 | time.sleep(1) |
152 | 145 | except KeyboardInterrupt: |
153 | | - logger.info("Stopping observer") |
154 | 146 | observer.stop() |
155 | | - |
156 | 147 | observer.join() |
157 | 148 |
|
158 | 149 |
|
159 | 150 | def parse_args() -> argparse.Namespace: |
160 | | - p = argparse.ArgumentParser(description="Watch ClipABit.py and copy to DaVinci Resolve Utility folder") |
161 | | - p.add_argument("--source", "-s", type=str, default=str(DEFAULT_SOURCE), help="Path to local ClipABit.py (relative to repo root or absolute)") |
162 | | - p.add_argument("--dest", "-d", type=str, default=str(get_resolve_script_dir()), help="Destination Utility folder") |
163 | | - p.add_argument("--name", "-n", type=str, default=DEFAULT_COPY_NAME, help="Filename to write at destination") |
164 | | - p.add_argument("--poll-interval", type=float, default=0.5, help="Polling interval when watchdog unavailable") |
| 151 | + p = argparse.ArgumentParser(description="Watch ClipABit plugin folder and sync to Resolve") |
| 152 | + p.add_argument("--source", "-s", type=str, default=str(DEFAULT_SOURCE), help="Path to plugin/ folder") |
| 153 | + p.add_argument("--poll", action="store_true", help="Force polling mode") |
165 | 154 | return p.parse_args() |
166 | 155 |
|
167 | 156 |
|
168 | 157 | def main(): |
169 | 158 | args = parse_args() |
170 | | - src = Path(args.source).expanduser() |
171 | | - dst_dir = Path(args.dest).expanduser() |
172 | | - |
173 | | - # If source is relative, resolve relative to repository root |
174 | | - if not src.is_absolute(): |
175 | | - # Script is in utils/plugins/davinci/, so go up 3 levels to get to repo root |
176 | | - # __file__ = monorepo-1/utils/plugins/davinci/watch_clipabit.py |
177 | | - # parents[0] = monorepo-1/utils/plugins/davinci/ |
178 | | - # parents[1] = monorepo-1/utils/plugins/ |
179 | | - # parents[2] = monorepo-1/utils/ |
180 | | - # parents[3] = monorepo-1/ (repo root) |
181 | | - script_path = Path(__file__).resolve() |
182 | | - repo_root = script_path.parents[3] |
183 | | - # Remove leading ../ from the source path if present |
184 | | - src_str = str(src).replace('\\', '/') |
185 | | - # Remove all leading ../ parts |
186 | | - while src_str.startswith('../'): |
187 | | - src_str = src_str[3:] # Remove one ../ |
188 | | - src = (repo_root / src_str).resolve() |
189 | | - |
190 | | - logger.info("Watching source: %s", src) |
191 | | - logger.info("Destination directory: %s", dst_dir) |
192 | | - |
193 | | - # Copy once at startup so target is up-to-date before watching |
194 | | - if src.exists(): |
195 | | - copy_file(src, dst_dir, args.name) |
196 | | - else: |
197 | | - logger.warning("Source file does not exist at startup: %s", src) |
| 159 | + script_dir = Path(__file__).resolve().parent |
| 160 | + src = (script_dir / args.source).resolve() |
| 161 | + |
| 162 | + if not src.exists(): |
| 163 | + logger.error("Source directory not found: %s", src) |
| 164 | + return |
198 | 165 |
|
199 | | - # Try the event-driven watcher first |
200 | | - try: |
201 | | - run_watchdog(src, dst_dir, args.name) |
202 | | - except Exception as e: |
203 | | - logger.exception("Watchdog observer failed, falling back to polling: %s", e) |
204 | | - run_polling(src, dst_dir, args.name, interval=args.poll_interval) |
| 166 | + resolve_paths = get_resolve_paths() |
| 167 | + logger.info("Watching: %s", src) |
| 168 | + logger.info("Target Utility: %s", resolve_paths["utility"]) |
| 169 | + logger.info("Target Modules: %s", resolve_paths["modules"]) |
| 170 | + |
| 171 | + # Initial sync |
| 172 | + sync_plugin(src, resolve_paths) |
| 173 | + |
| 174 | + if args.poll: |
| 175 | + run_polling(src, resolve_paths) |
| 176 | + else: |
| 177 | + run_watchdog(src, resolve_paths) |
205 | 178 |
|
206 | 179 |
|
207 | 180 | if __name__ == "__main__": |
|
0 commit comments