diff --git a/README.md b/README.md
index 2b695c9..aeeb123 100644
--- a/README.md
+++ b/README.md
@@ -325,7 +325,7 @@ Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends fu
|------|-------------|
| `FL_Audio_BPM_Analyzer` | Analyzes audio using librosa to detect BPM and beat positions, with options for two BPM calculation methods (beat_intervals or onset_strength), beat offset adjustment, and automatic beat filling to cover the entire audio duration. Outputs beat positions as JSON with visualization. |
| `FL_Audio_Beat_Prompt_Envelope` | Turns exact detected beats into attack/hold/release prompt-mask pulses for compatible FL diffusion video nodes. Supports beat stride, phase, response curve, and on-beat dominance above normal conditioning weight. |
-| `FL_Audio_Beat_Prompt_Schedule` | Integrated audio and prompt sequencer with native upload, transport, source trimming, immediate waveform display, queue-free beat/onset/drum analysis, optional cached stem analysis, a directly draggable beat grid with every-beat and subdivision density controls, and frame-native prompt clips. Outputs the cropped master audio plus beat and drum JSON for the rest of the FL audio ecosystem. |
+| `FL_Audio_Beat_Prompt_Schedule` | Integrated audio and prompt sequencer with separate source-trim and sequence timelines, a stationary cropped waveform and audio reference markers, magnetic phase alignment for the directly draggable beat grid, every-beat and subdivision density controls, and frame-native prompt clips. Outputs the cropped master audio plus the visually aligned beat and drum JSON for the rest of the FL audio ecosystem. |
| `FL_Audio_Beat_Visualizer` | Generates video frames that visualize beat patterns by either alternating between black/white on beat changes or cycling through provided images, with configurable frame dimensions and starting color. |
| `FL_AudioFrameCalculator` | Given an AUDIO and a target FPS, returns the integer frame count needed to cover the audio's duration (`ceil(duration * fps)`). Useful upstream of video-generation nodes that need an exact frame budget. |
| `FL_Audio_Crop` | Crops audio waveforms to specified start and end times using MM:SS or seconds format, with automatic clamping to valid audio boundaries. |
diff --git a/nodes/audio/FL_Audio_Beat_Prompt_Schedule.py b/nodes/audio/FL_Audio_Beat_Prompt_Schedule.py
index 75ad2fc..623c9a4 100644
--- a/nodes/audio/FL_Audio_Beat_Prompt_Schedule.py
+++ b/nodes/audio/FL_Audio_Beat_Prompt_Schedule.py
@@ -85,6 +85,8 @@ def _load_beat_data(beat_positions):
return {
"bpm": bpm,
"grid_bpm": data.get("grid_bpm", bpm),
+ "base_grid_interval_seconds": data.get("base_grid_interval_seconds", 0.0),
+ "grid_interval_seconds": data.get("grid_interval_seconds", 0.0),
"beat_grid_density": data.get("beat_grid_density", "every_beat"),
"beat_times": beat_times,
"base_beat_times": base_beat_times,
@@ -486,8 +488,8 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode):
max=1000,
step=1,
tooltip=(
- "Backing value for the sequencer's live Beat offset control. It shifts the "
- "beat grid and detected beats without moving audio, onsets, drums, or prompts."
+ "Backing value for the sequencer's live Beat offset control. It shifts only "
+ "the regular beat grid while audio-derived reference markers remain fixed."
),
),
io.Combo.Input(
@@ -646,6 +648,8 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode):
ui_payload = {
"bpm": beat_data["bpm"],
"grid_bpm": beat_data["grid_bpm"],
+ "base_grid_interval_seconds": beat_data["base_grid_interval_seconds"],
+ "grid_interval_seconds": beat_data["grid_interval_seconds"],
"beat_grid_density": beat_data["beat_grid_density"],
"beat_times": beat_times,
"base_beat_times": beat_data["base_beat_times"],
diff --git a/nodes/audio/audio_timeline.py b/nodes/audio/audio_timeline.py
index a9d1bf0..f38c16c 100644
--- a/nodes/audio/audio_timeline.py
+++ b/nodes/audio/audio_timeline.py
@@ -13,7 +13,7 @@ from .audio_files import audio_file_hash, load_audio_file, resolve_audio_path
from .audio_separation import load_cached_stem
-ANALYSIS_VERSION = 3
+ANALYSIS_VERSION = 4
DETECTOR_VERSION = "fl-audio-timeline-2"
_WAVEFORM_BUCKETS_PER_SECOND = 60
_MAX_WAVEFORM_BUCKETS = 8192
@@ -212,37 +212,63 @@ def apply_beat_offset(analysis, fps, beat_offset_ms=0, beat_grid_density="every_
duration = float(analysis["audio_duration"])
offset = beat_offset_ms / 1000.0
- base_beat_times = analysis.get("base_beat_times", analysis.get("beat_times", []))
+ base_beat_times = list(
+ analysis.get("base_beat_times", analysis.get("beat_times", []))
+ )
+ if not base_beat_times:
+ raise ValueError("Beat analysis must contain at least one beat.")
base_detected_beat_times = analysis.get(
"base_detected_beat_times",
analysis.get("detected_beat_times", []),
)
- if beat_grid_density == "every_2_beats":
- grid_beat_times = base_beat_times[::2]
- grid_bpm_scale = 0.5
- elif beat_grid_density == "half_beat":
- grid_beat_times = []
- for index, beat_time in enumerate(base_beat_times):
- grid_beat_times.append(beat_time)
- if index + 1 < len(base_beat_times):
- grid_beat_times.append((beat_time + base_beat_times[index + 1]) / 2.0)
- grid_bpm_scale = 2.0
- else:
- grid_beat_times = base_beat_times
- grid_bpm_scale = 1.0
+ base_grid_interval = float(analysis.get("base_grid_interval_seconds", 0.0))
+ if base_grid_interval <= 0 and len(base_beat_times) > 1:
+ base_grid_interval = float(np.median(np.diff(base_beat_times)))
+ if base_grid_interval <= 0:
+ bpm = float(analysis.get("bpm", 0.0))
+ base_grid_interval = 60.0 / bpm if bpm > 0 else 0.0
+ if not math.isfinite(base_grid_interval) or base_grid_interval <= 0:
+ raise ValueError("Beat analysis must provide a valid beat interval or BPM.")
- def shifted(values):
- if not values:
- return []
- times = np.asarray(values, dtype=np.float64)
- return np.unique(np.clip(times + offset, 0, duration)).tolist()
+ if beat_grid_density == "every_2_beats":
+ base_grid = base_beat_times[::2]
+ grid_interval = base_grid_interval * 2.0
+ elif beat_grid_density == "half_beat":
+ base_grid = []
+ for index, beat_time in enumerate(base_beat_times):
+ base_grid.append(beat_time)
+ if index + 1 < len(base_beat_times):
+ base_grid.append((beat_time + base_beat_times[index + 1]) / 2.0)
+ grid_interval = base_grid_interval / 2.0
+ else:
+ base_grid = base_beat_times
+ grid_interval = base_grid_interval
+
+ shifted_grid = [beat_time + offset for beat_time in base_grid]
+ grid_beat_times = [
+ beat_time for beat_time in shifted_grid if 0.0 <= beat_time < duration
+ ]
+ if offset > 0:
+ beat_time = shifted_grid[0] - grid_interval
+ while beat_time >= 0:
+ if beat_time < duration:
+ grid_beat_times.insert(0, beat_time)
+ beat_time -= grid_interval
+ elif offset < 0:
+ beat_time = shifted_grid[-1] + grid_interval
+ while beat_time < duration:
+ if beat_time >= 0:
+ grid_beat_times.append(beat_time)
+ beat_time += grid_interval
result = dict(analysis)
- result["base_beat_times"] = list(base_beat_times)
+ result["version"] = ANALYSIS_VERSION
+ result["base_beat_times"] = base_beat_times
result["base_detected_beat_times"] = list(base_detected_beat_times)
- result["beat_times"] = shifted(grid_beat_times)
- result["detected_beat_times"] = shifted(base_detected_beat_times)
+ result["base_grid_interval_seconds"] = base_grid_interval
+ result["beat_times"] = grid_beat_times
+ result["detected_beat_times"] = list(base_detected_beat_times)
result["beat_frames"] = [round(value * fps) for value in result["beat_times"]]
result["detected_beat_frames"] = [
round(value * fps) for value in result["detected_beat_times"]
@@ -250,7 +276,8 @@ def apply_beat_offset(analysis, fps, beat_offset_ms=0, beat_grid_density="every_
result["num_beats"] = len(result["beat_times"])
result["beat_offset_ms"] = int(round(beat_offset_ms))
result["beat_grid_density"] = beat_grid_density
- result["grid_bpm"] = float(result.get("bpm", 0.0)) * grid_bpm_scale
+ result["grid_interval_seconds"] = grid_interval
+ result["grid_bpm"] = 60.0 / grid_interval
return result
@@ -313,6 +340,7 @@ def analyze_audio(
"detector_version": DETECTOR_VERSION,
"bpm": float(bpm),
"bpm_source": bpm_source,
+ "base_grid_interval_seconds": float(interval),
"beat_times": beat_times,
"detected_beat_times": detected_times,
"onset_times": onsets,
diff --git a/pyproject.toml b/pyproject.toml
index 4a04298..0dabf78 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "comfyui_fill-nodes"
description = "Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends functionality across multiple domains. Features include advanced image processing (pixelation, slicing, masking), visual effects generation (glitch, halftone, pixel art), comprehensive file handling (PDF creation/extraction, Google Drive integration), AI model interfaces (GPT, DALL-E, Hugging Face), utility nodes for workflow enhancement, and specialized tools for video processing, captioning, and batch operations. The pack provides both practical workflow solutions and creative tools within a unified node collection."
-version = "2.18.0"
+version = "2.19.0"
license = {file = "LICENSE"}
dependencies = ["librosa", "sounddevice", "glitch_this", "PyOpenGL", "glfw", "scipy>=1.13.1", "requests", "aiohttp", "moviepy", "matplotlib", "reportlab", "openai", "PyPDF2", "pdf2image", "PyMuPDF", "reportlab", "PyPDF2", "ollama", "kornia", "opencv-python", "gdown", "open_clip_torch", "google-genai"]
diff --git a/tests/test_audio_beat_prompt_schedule.py b/tests/test_audio_beat_prompt_schedule.py
index 4b4ef60..ed51080 100644
--- a/tests/test_audio_beat_prompt_schedule.py
+++ b/tests/test_audio_beat_prompt_schedule.py
@@ -370,6 +370,7 @@ class BeatPromptScheduleTests(unittest.TestCase):
self.assertEqual(effective["base_beat_times"], [0.1, 0.6, 1.2, 1.9])
self.assertEqual(effective["beat_times"], [0.2, 0.7, 1.3, 2.0])
self.assertEqual(effective["beat_offset_ms"], 100)
+ self.assertEqual(effective["grid_interval_seconds"], 0.6)
self.assertEqual(payload["base_beat_times"], [0.1, 0.6, 1.2, 1.9])
self.assertEqual(payload["beat_times"], [0.2, 0.7, 1.3, 2.0])
self.assertEqual(payload["beat_offset_ms"], 100)
@@ -391,7 +392,7 @@ class BeatPromptScheduleTests(unittest.TestCase):
self.assertEqual(effective["beat_times"], [0.1, 1.2])
self.assertEqual(effective["base_beat_times"], [0.1, 0.6, 1.2, 1.9])
self.assertEqual(effective["beat_grid_density"], "every_2_beats")
- self.assertEqual(effective["grid_bpm"], 60.0)
+ self.assertEqual(effective["grid_bpm"], 50.0)
self.assertEqual(payload["beat_times"], [0.1, 1.2])
self.assertEqual(payload["beat_grid_density"], "every_2_beats")
diff --git a/tests/test_audio_timeline.py b/tests/test_audio_timeline.py
index c22a2e2..87918e6 100644
--- a/tests/test_audio_timeline.py
+++ b/tests/test_audio_timeline.py
@@ -22,7 +22,7 @@ SPEC.loader.exec_module(timeline)
class AudioTimelineTests(unittest.TestCase):
- def test_beat_offset_shifts_only_beat_markers(self):
+ def test_beat_offset_shifts_only_the_regular_grid(self):
analysis = {
"audio_duration": 1.0,
"beat_times": [0.1, 0.9],
@@ -34,24 +34,43 @@ class AudioTimelineTests(unittest.TestCase):
shifted = timeline.apply_beat_offset(analysis, fps=24.0, beat_offset_ms=200)
self.assertEqual(shifted["base_beat_times"], [0.1, 0.9])
- self.assertEqual(shifted["beat_times"], [0.30000000000000004, 1.0])
- self.assertEqual(shifted["detected_beat_times"], [0.2, 1.0])
- self.assertEqual(shifted["beat_frames"], [7, 24])
+ self.assertEqual(shifted["beat_times"], [0.30000000000000004])
+ self.assertEqual(shifted["detected_beat_times"], [0.0, 0.95])
+ self.assertEqual(shifted["beat_frames"], [7])
+ self.assertEqual(shifted["detected_beat_frames"], [0, 23])
self.assertEqual(shifted["onset_times"], [0.2])
self.assertEqual(shifted["drum_times"], {"kick_times": [0.2]})
self.assertEqual(shifted["beat_offset_ms"], 200)
+ self.assertEqual(shifted["grid_interval_seconds"], 0.8)
- def test_beat_offset_clamps_and_deduplicates_crop_boundaries(self):
+ def test_beat_offset_preserves_periodic_spacing_at_crop_boundaries(self):
analysis = {
"audio_duration": 1.0,
- "beat_times": [0.0, 0.1, 0.8],
+ "beat_times": [0.0, 0.4, 0.8],
"detected_beat_times": [],
}
shifted = timeline.apply_beat_offset(analysis, fps=24.0, beat_offset_ms=-200)
- self.assertEqual(shifted["beat_times"], [0.0, 0.6000000000000001])
- self.assertEqual(shifted["base_beat_times"], [0.0, 0.1, 0.8])
+ self.assertEqual(shifted["beat_times"], [0.2, 0.6000000000000001])
+ self.assertEqual(shifted["base_beat_times"], [0.0, 0.4, 0.8])
+ self.assertAlmostEqual(
+ shifted["beat_times"][1] - shifted["beat_times"][0],
+ 0.4,
+ )
+
+ def test_beat_offset_keeps_the_grid_visible_after_all_native_beats_leave(self):
+ analysis = {
+ "audio_duration": 0.5,
+ "bpm": 120.0,
+ "beat_times": [0.1],
+ "detected_beat_times": [0.1],
+ }
+
+ shifted = timeline.apply_beat_offset(analysis, fps=24.0, beat_offset_ms=1000)
+
+ self.assertAlmostEqual(shifted["beat_times"][0], 0.1)
+ self.assertEqual(shifted["detected_beat_times"], [0.1])
def test_beat_grid_density_uses_native_beats_as_its_source(self):
analysis = {
@@ -86,8 +105,9 @@ class AudioTimelineTests(unittest.TestCase):
subdivisions["beat_times"],
[0.1, 0.35, 0.6, 0.85, 1.1, 1.35, 1.6],
)
- self.assertEqual(subdivisions["detected_beat_times"], [0.15000000000000002, 0.65])
+ self.assertEqual(subdivisions["detected_beat_times"], [0.05, 0.55])
self.assertEqual(subdivisions["grid_bpm"], 240.0)
+ self.assertEqual(subdivisions["grid_interval_seconds"], 0.25)
self.assertEqual(subdivisions["beat_grid_density"], "half_beat")
def test_unknown_beat_grid_density_is_rejected(self):
@@ -209,7 +229,7 @@ class AudioTimelineTests(unittest.TestCase):
master = {"waveform": torch.ones(1, 1, 48000), "sample_rate": 48000}
analysis = {
"bpm": 120.0,
- "beat_times": [0.0, 0.5, 1.0],
+ "beat_times": [0.0, 0.25, 0.5, 0.75],
"detected_beat_times": [],
"onset_times": [],
"audio_duration": 1.0,
@@ -233,8 +253,8 @@ class AudioTimelineTests(unittest.TestCase):
)
self.assertEqual(analyze.call_count, 1)
- self.assertEqual(every_beat["beat_times"], [0.0, 0.5, 1.0])
- self.assertEqual(every_two["beat_times"], [0.0, 1.0])
+ self.assertEqual(every_beat["beat_times"], [0.0, 0.25, 0.5, 0.75])
+ self.assertEqual(every_two["beat_times"], [0.0, 0.5])
self.assertEqual(every_beat["cache_key"], every_two["cache_key"])
diff --git a/web/nodes/audio/FL_Audio_Beat_Prompt_Schedule.js b/web/nodes/audio/FL_Audio_Beat_Prompt_Schedule.js
index 9828000..d94b805 100644
--- a/web/nodes/audio/FL_Audio_Beat_Prompt_Schedule.js
+++ b/web/nodes/audio/FL_Audio_Beat_Prompt_Schedule.js
@@ -5,18 +5,21 @@ const STYLE_ID = "fl-beat-prompt-sequencer-styles";
const INSTANCES = new Map();
const HEADER_RE = /^\s*\[\s*([0-9]+(?:\.[0-9]+)?)\s*-\s*([0-9]+(?:\.[0-9]+)?)(?:\s*\|\s*(.*?))?\s*\]\s*$/;
const EPSILON = 1e-6;
-const FORMAT_VERSION = 5;
+const FORMAT_VERSION = 6;
const MIN_NODE_WIDTH = 680;
const MIN_NODE_HEIGHT = 1100;
const TIMELINE_LEFT = 42;
const TIMELINE_RIGHT = 12;
-const BEAT_LANE_TOP = 31;
-const BEAT_LANE_BOTTOM = 58;
const GRID_DENSITY_LABELS = {
every_2_beats: "Every 2 beats",
every_beat: "Every beat",
half_beat: "Half-beat",
};
+const MAGNET_LABELS = {
+ detected: "Detected beats",
+ onset: "Onsets",
+ off: "Off",
+};
const STYLES = `
.flbps-root {
@@ -468,6 +471,7 @@ class BeatPromptSequencer {
this.selectedIndex = -1;
this.playheadFrame = null;
this.snapGuideFrame = null;
+ this.beatAlignmentGuide = null;
this.drag = null;
this.clipRects = [];
this.pendingFrame = null;
@@ -495,6 +499,7 @@ class BeatPromptSequencer {
this.viewStart = saved.formatVersion === FORMAT_VERSION ? finiteNumber(saved.viewStart, 0) : 0;
this.viewEnd = saved.formatVersion === FORMAT_VERSION ? finiteNumber(saved.viewEnd, 0) : 0;
this.snapMode = ["beat", "detected", "onset", "frame", "off"].includes(saved.snapMode) ? saved.snapMode : "beat";
+ this.magnetMode = saved.magnetMode in MAGNET_LABELS ? saved.magnetMode : "detected";
this.waveformVisible = saved.waveformVisible !== false;
this.autoAnalyze = saved.autoAnalyze !== false;
@@ -573,7 +578,15 @@ class BeatPromptSequencer {
-