Skip to content

Commit 08929e1

Browse files
Fix watchdog script (#13)
* Refactor: Modularize plugin into split package structure * Fixed plugin, split up the files, cleaned up the split up, and then fixed up uploading faliure * hopefully this fixes some CI issues * clean up some lint errors * logging fixes * Update watchdog script to support modular plugin structure
1 parent f519caa commit 08929e1

File tree

1 file changed

+103
-130
lines changed

1 file changed

+103
-130
lines changed

watch_clipabit.py

Lines changed: 103 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -30,178 +30,151 @@
3030
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
3131

3232

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"
3536

3637

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."""
4240
home = Path.home()
4341
system = platform.system()
4442

4543
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'
4844
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)
8187
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):
9097
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)
96102

97103

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."""
100106
try:
101107
from watchdog.observers import Observer
102108
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)
106111

107112
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
120117

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
122123
now = time.time()
123-
if (now - self._last_copied) < 0.2:
124+
if (now - self._last_sync) < 0.5:
124125
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
132128
try:
133129
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)
134133
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
139135

140-
handler = Handler(src.resolve(), dst_dir, dst_name)
136+
handler = Handler(src, paths)
141137
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)
146139
observer.start()
147140
logger.info("Started watchdog observer for %s", src)
148141

149142
try:
150143
while True:
151144
time.sleep(1)
152145
except KeyboardInterrupt:
153-
logger.info("Stopping observer")
154146
observer.stop()
155-
156147
observer.join()
157148

158149

159150
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")
165154
return p.parse_args()
166155

167156

168157
def main():
169158
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
198165

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)
205178

206179

207180
if __name__ == "__main__":

0 commit comments

Comments
 (0)