Files
hyperframes/packages/cli/src/server/fileWatcher.ts
T
Miguel Angel Simon Sierra 1b8b2ac425 fix(studio): fix array-form keyframe writes, diamond click-deselect, and nested video sync
- fs.watch's async 'error' event had no listener, crashing the preview
  server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
  required object-form keyframes: {"0%": {...}}, silently no-opping on
  array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
  to the ancestor clip's onClick, which toggles selection off when the
  clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
  covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
  duration:0 + immediateRender static hold (what remove-all-keyframes
  produces) as non-animated, so it kept showing a phantom diamond after
  Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
  data-start discarded the host composition's inherited start offset,
  so a video nested inside a sub-composition played from the root
  timeline's time instead of holding until its parent scene began

Fixes #1838
2026-07-01 18:07:44 -07:00

77 lines
2.1 KiB
TypeScript

import { watch, type FSWatcher } from "node:fs";
export type FileChangeListener = (relativePath: string) => void;
export interface ProjectWatcher {
addListener(fn: FileChangeListener): void;
removeListener(fn: FileChangeListener): void;
close(): void;
}
const WATCHER_EXCLUDED_DIRS = new Set([
".cache",
".git",
".hyperframes",
".next",
".vite",
"build",
"coverage",
"dist",
"node_modules",
"outputs",
"renders",
]);
const DEBOUNCE_MS = 300;
export function shouldWatchProjectFile(filename: string): boolean {
if (!filename) return false;
const parts = filename.split(/[\\/]+/);
return !parts.some((part) => WATCHER_EXCLUDED_DIRS.has(part));
}
export function createProjectWatcher(projectDir: string): ProjectWatcher {
const listeners = new Set<FileChangeListener>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let watcher: FSWatcher | null = null;
try {
watcher = watch(projectDir, { recursive: true }, (_event, filename) => {
if (!filename) return;
const relativePath = filename.toString();
if (!shouldWatchProjectFile(relativePath)) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
for (const fn of listeners) {
fn(relativePath);
}
}, DEBOUNCE_MS);
});
// fs.watch can fail asynchronously too (e.g. EMFILE from exhausted OS watch
// handles) — that surfaces as an 'error' event, not a thrown exception. An
// EventEmitter 'error' with no listener crashes the whole process, so this
// listener is required for the same "degrade gracefully" the catch below
// already promises for the synchronous failure mode.
watcher.on("error", () => {
watcher?.close();
watcher = null;
});
} catch {
// fs.watch may fail on some platforms — degrade gracefully (no auto-refresh)
}
return {
addListener(fn) {
listeners.add(fn);
},
removeListener(fn) {
listeners.delete(fn);
},
close() {
if (debounceTimer) clearTimeout(debounceTimer);
watcher?.close();
listeners.clear();
},
};
}