Make beat grid directly editable
This commit is contained in:
@@ -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, and draggable 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 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_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. |
|
||||
|
||||
@@ -84,6 +84,8 @@ def _load_beat_data(beat_positions):
|
||||
base_detected_beat_times = []
|
||||
return {
|
||||
"bpm": bpm,
|
||||
"grid_bpm": data.get("grid_bpm", bpm),
|
||||
"beat_grid_density": data.get("beat_grid_density", "every_beat"),
|
||||
"beat_times": beat_times,
|
||||
"base_beat_times": base_beat_times,
|
||||
"audio_duration": duration,
|
||||
@@ -498,6 +500,16 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode):
|
||||
"available after separation finishes."
|
||||
),
|
||||
),
|
||||
io.Combo.Input(
|
||||
"beat_grid_density",
|
||||
display_name="beat grid density",
|
||||
options=["every_2_beats", "every_beat", "half_beat"],
|
||||
default="every_beat",
|
||||
tooltip=(
|
||||
"Backing value for the sequencer's Grid control. Every beat uses the "
|
||||
"detected tempo; half-beat adds subdivisions."
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
FLPromptSchedule.Output(
|
||||
@@ -548,6 +560,7 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode):
|
||||
half_time=False,
|
||||
beat_offset_ms=0,
|
||||
analysis_source="mix",
|
||||
beat_grid_density="every_beat",
|
||||
):
|
||||
internal_analysis = None
|
||||
cropped_audio = None
|
||||
@@ -561,11 +574,17 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode):
|
||||
half_time,
|
||||
beat_offset_ms,
|
||||
analysis_source,
|
||||
beat_grid_density,
|
||||
)
|
||||
if beat_positions:
|
||||
beat_payload = _parse_beat_payload(beat_positions)
|
||||
_load_beat_data(beat_payload)
|
||||
beat_payload = apply_beat_offset(beat_payload, fps, beat_offset_ms)
|
||||
beat_payload = apply_beat_offset(
|
||||
beat_payload,
|
||||
fps,
|
||||
beat_offset_ms,
|
||||
beat_grid_density,
|
||||
)
|
||||
beat_positions = json.dumps(beat_payload, separators=(",", ":"))
|
||||
beat_data = _load_beat_data(beat_payload)
|
||||
if internal_analysis is not None:
|
||||
@@ -626,6 +645,8 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode):
|
||||
}
|
||||
ui_payload = {
|
||||
"bpm": beat_data["bpm"],
|
||||
"grid_bpm": beat_data["grid_bpm"],
|
||||
"beat_grid_density": beat_data["beat_grid_density"],
|
||||
"beat_times": beat_times,
|
||||
"base_beat_times": beat_data["base_beat_times"],
|
||||
"detected_beat_times": (
|
||||
|
||||
@@ -202,11 +202,13 @@ def _detect_drums(waveform, sample_rate, onset_frames, onset_times):
|
||||
}
|
||||
|
||||
|
||||
def apply_beat_offset(analysis, fps, beat_offset_ms=0):
|
||||
def apply_beat_offset(analysis, fps, beat_offset_ms=0, beat_grid_density="every_beat"):
|
||||
if not math.isfinite(fps) or fps <= 0:
|
||||
raise ValueError("FPS must be greater than zero.")
|
||||
if not math.isfinite(beat_offset_ms):
|
||||
raise ValueError("Beat offset must be a finite number.")
|
||||
if beat_grid_density not in {"every_2_beats", "every_beat", "half_beat"}:
|
||||
raise ValueError(f"Unknown beat grid density: {beat_grid_density}")
|
||||
|
||||
duration = float(analysis["audio_duration"])
|
||||
offset = beat_offset_ms / 1000.0
|
||||
@@ -216,6 +218,20 @@ def apply_beat_offset(analysis, fps, beat_offset_ms=0):
|
||||
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
|
||||
|
||||
def shifted(values):
|
||||
if not values:
|
||||
return []
|
||||
@@ -225,7 +241,7 @@ def apply_beat_offset(analysis, fps, beat_offset_ms=0):
|
||||
result = dict(analysis)
|
||||
result["base_beat_times"] = list(base_beat_times)
|
||||
result["base_detected_beat_times"] = list(base_detected_beat_times)
|
||||
result["beat_times"] = shifted(base_beat_times)
|
||||
result["beat_times"] = shifted(grid_beat_times)
|
||||
result["detected_beat_times"] = shifted(base_detected_beat_times)
|
||||
result["beat_frames"] = [round(value * fps) for value in result["beat_times"]]
|
||||
result["detected_beat_frames"] = [
|
||||
@@ -233,10 +249,19 @@ def apply_beat_offset(analysis, fps, beat_offset_ms=0):
|
||||
]
|
||||
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
|
||||
return result
|
||||
|
||||
|
||||
def analyze_audio(audio, fps, bpm_method="beat_intervals", half_time=False, beat_offset_ms=0):
|
||||
def analyze_audio(
|
||||
audio,
|
||||
fps,
|
||||
bpm_method="beat_intervals",
|
||||
half_time=False,
|
||||
beat_offset_ms=0,
|
||||
beat_grid_density="every_beat",
|
||||
):
|
||||
if bpm_method not in {"beat_intervals", "onset_strength"}:
|
||||
raise ValueError(f"Unknown BPM method: {bpm_method}")
|
||||
waveform = mono_numpy(audio)
|
||||
@@ -300,7 +325,7 @@ def analyze_audio(audio, fps, bpm_method="beat_intervals", half_time=False, beat
|
||||
"drum_times": drum_times,
|
||||
"waveform_preview": waveform_preview(waveform, sample_rate),
|
||||
}
|
||||
return apply_beat_offset(analysis, fps, beat_offset_ms)
|
||||
return apply_beat_offset(analysis, fps, beat_offset_ms, beat_grid_density)
|
||||
|
||||
|
||||
def analysis_cache_key(path, fps, trim_start_frame, length_frames, bpm_method, half_time, analysis_source):
|
||||
@@ -333,6 +358,7 @@ def analyze_audio_file(
|
||||
half_time=False,
|
||||
beat_offset_ms=0,
|
||||
analysis_source="mix",
|
||||
beat_grid_density="every_beat",
|
||||
):
|
||||
path = resolve_audio_path(filename)
|
||||
_, master_audio = load_audio_file(filename)
|
||||
@@ -370,4 +396,9 @@ def analyze_audio_file(
|
||||
temporary_path = cache_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(json.dumps(analysis, separators=(",", ":")), encoding="utf-8")
|
||||
temporary_path.replace(cache_path)
|
||||
return apply_beat_offset(analysis, fps, beat_offset_ms), cropped_audio
|
||||
return apply_beat_offset(
|
||||
analysis,
|
||||
fps,
|
||||
beat_offset_ms,
|
||||
beat_grid_density,
|
||||
), cropped_audio
|
||||
|
||||
+1
-1
@@ -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.17.0"
|
||||
version = "2.18.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"]
|
||||
|
||||
|
||||
@@ -274,6 +274,7 @@ class BeatPromptScheduleTests(unittest.TestCase):
|
||||
"half_time",
|
||||
"beat_offset_ms",
|
||||
"analysis_source",
|
||||
"beat_grid_density",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
@@ -373,6 +374,27 @@ class BeatPromptScheduleTests(unittest.TestCase):
|
||||
self.assertEqual(payload["beat_times"], [0.2, 0.7, 1.3, 2.0])
|
||||
self.assertEqual(payload["beat_offset_ms"], 100)
|
||||
|
||||
def test_scheduler_density_controls_external_grid_and_output(self):
|
||||
output = schedule.FL_Audio_Beat_Prompt_Schedule.execute(
|
||||
beat_positions=beat_json(),
|
||||
timeline="[0 - 24]\nCamera pulse.",
|
||||
default_fade_in=0.0,
|
||||
default_fade_out=0.0,
|
||||
curve="linear",
|
||||
fps=24.0,
|
||||
sequence_duration=24,
|
||||
beat_grid_density="every_2_beats",
|
||||
)
|
||||
|
||||
effective = json.loads(output.result[5])
|
||||
payload = output.ui["fl_prompt_sequencer"][0]
|
||||
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(payload["beat_times"], [0.1, 1.2])
|
||||
self.assertEqual(payload["beat_grid_density"], "every_2_beats")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -53,6 +53,51 @@ class AudioTimelineTests(unittest.TestCase):
|
||||
self.assertEqual(shifted["beat_times"], [0.0, 0.6000000000000001])
|
||||
self.assertEqual(shifted["base_beat_times"], [0.0, 0.1, 0.8])
|
||||
|
||||
def test_beat_grid_density_uses_native_beats_as_its_source(self):
|
||||
analysis = {
|
||||
"bpm": 120.0,
|
||||
"audio_duration": 2.0,
|
||||
"beat_times": [0.0, 0.5, 1.0, 1.5],
|
||||
"detected_beat_times": [0.05, 0.55],
|
||||
}
|
||||
|
||||
every_two = timeline.apply_beat_offset(
|
||||
analysis,
|
||||
fps=24.0,
|
||||
beat_grid_density="every_2_beats",
|
||||
)
|
||||
every_beat = timeline.apply_beat_offset(
|
||||
every_two,
|
||||
fps=24.0,
|
||||
beat_grid_density="every_beat",
|
||||
)
|
||||
subdivisions = timeline.apply_beat_offset(
|
||||
analysis,
|
||||
fps=24.0,
|
||||
beat_offset_ms=100,
|
||||
beat_grid_density="half_beat",
|
||||
)
|
||||
|
||||
self.assertEqual(every_two["beat_times"], [0.0, 1.0])
|
||||
self.assertEqual(every_two["grid_bpm"], 60.0)
|
||||
self.assertEqual(every_beat["beat_times"], [0.0, 0.5, 1.0, 1.5])
|
||||
self.assertEqual(every_beat["grid_bpm"], 120.0)
|
||||
self.assertEqual(
|
||||
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["grid_bpm"], 240.0)
|
||||
self.assertEqual(subdivisions["beat_grid_density"], "half_beat")
|
||||
|
||||
def test_unknown_beat_grid_density_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "Unknown beat grid density"):
|
||||
timeline.apply_beat_offset(
|
||||
{"audio_duration": 1.0, "beat_times": [0.0]},
|
||||
fps=24.0,
|
||||
beat_grid_density="bars",
|
||||
)
|
||||
|
||||
def test_crop_uses_video_frames_for_sample_boundaries(self):
|
||||
audio = {
|
||||
"waveform": torch.arange(0, 96000, dtype=torch.float32).reshape(1, 1, -1),
|
||||
@@ -160,6 +205,38 @@ class AudioTimelineTests(unittest.TestCase):
|
||||
self.assertEqual(shifted["beat_times"], [0.2, 0.7])
|
||||
self.assertEqual(first["cache_key"], shifted["cache_key"])
|
||||
|
||||
def test_density_changes_reuse_the_base_analysis_cache(self):
|
||||
master = {"waveform": torch.ones(1, 1, 48000), "sample_rate": 48000}
|
||||
analysis = {
|
||||
"bpm": 120.0,
|
||||
"beat_times": [0.0, 0.5, 1.0],
|
||||
"detected_beat_times": [],
|
||||
"onset_times": [],
|
||||
"audio_duration": 1.0,
|
||||
"waveform_preview": {"version": 1, "duration": 1.0, "scale": 32767, "peaks": [0, 1]},
|
||||
"drum_times": {},
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
cache_path = pathlib.Path(directory) / "analysis.json"
|
||||
with (
|
||||
mock.patch.object(timeline, "resolve_audio_path", return_value=pathlib.Path("song.wav")),
|
||||
mock.patch.object(timeline, "load_audio_file", return_value=(pathlib.Path("song.wav"), master)),
|
||||
mock.patch.object(timeline, "analysis_cache_key", return_value="key"),
|
||||
mock.patch.object(timeline, "_cache_path", return_value=cache_path),
|
||||
mock.patch.object(timeline, "analyze_audio", return_value=analysis) as analyze,
|
||||
):
|
||||
every_beat, _ = timeline.analyze_audio_file("song.wav", fps=24.0)
|
||||
every_two, _ = timeline.analyze_audio_file(
|
||||
"song.wav",
|
||||
fps=24.0,
|
||||
beat_grid_density="every_2_beats",
|
||||
)
|
||||
|
||||
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["cache_key"], every_two["cache_key"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -5,11 +5,18 @@ 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 = 4;
|
||||
const FORMAT_VERSION = 5;
|
||||
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 STYLES = `
|
||||
.flbps-root {
|
||||
@@ -510,6 +517,11 @@ class BeatPromptSequencer {
|
||||
return clamp(Math.round(finiteNumber(this.widgets.beatOffset?.value, 0)), -1000, 1000);
|
||||
}
|
||||
|
||||
beatGridDensity() {
|
||||
const value = this.widgets.beatGridDensity?.value;
|
||||
return value in GRID_DENSITY_LABELS ? value : "every_beat";
|
||||
}
|
||||
|
||||
configuredFrameCount() {
|
||||
return Math.max(0, Math.round(finiteNumber(this.widgets.sequenceDuration?.value, 0)));
|
||||
}
|
||||
@@ -553,6 +565,14 @@ class BeatPromptSequencer {
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flbps-control" title="Choose the spacing of the cyan grid used for display, snapping, and beat_positions output.">
|
||||
Grid
|
||||
<select data-role="beat-grid-density">
|
||||
<option value="every_2_beats">Every 2 beats</option>
|
||||
<option value="every_beat">Every beat</option>
|
||||
<option value="half_beat">Half-beat</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flbps-control" title="Shift the beat grid and detected-beat markers without moving the audio, waveform, onsets, drums, or prompt clips.">
|
||||
Beat offset
|
||||
<input data-role="beat-offset" type="range" min="-1000" max="1000" step="1">
|
||||
@@ -603,7 +623,7 @@ class BeatPromptSequencer {
|
||||
</div>
|
||||
<div class="flbps-footer">
|
||||
<span class="flbps-summary" data-role="summary"></span>
|
||||
<span>drag edges/handles · wheel zoom · Shift+wheel pan · Shift bypasses beat snap</span>
|
||||
<span>drag BEAT GRID to align · drag prompt edges/handles · wheel zoom · Shift bypasses snap</span>
|
||||
</div>
|
||||
`;
|
||||
this.container.appendChild(this.root);
|
||||
@@ -621,6 +641,7 @@ class BeatPromptSequencer {
|
||||
this.sourceLabelEl = this.root.querySelector('[data-role="source-label"]');
|
||||
this.controls = {
|
||||
snap: this.root.querySelector('[data-role="snap"]'),
|
||||
beatGridDensity: this.root.querySelector('[data-role="beat-grid-density"]'),
|
||||
autoAnalyze: this.root.querySelector('[data-role="auto-analyze"]'),
|
||||
beatOffset: this.root.querySelector('[data-role="beat-offset"]'),
|
||||
beatOffsetNumber: this.root.querySelector('[data-role="beat-offset-number"]'),
|
||||
@@ -639,6 +660,7 @@ class BeatPromptSequencer {
|
||||
];
|
||||
|
||||
this.controls.snap.value = this.snapMode;
|
||||
this.controls.beatGridDensity.value = this.beatGridDensity();
|
||||
this.controls.autoAnalyze.checked = this.autoAnalyze;
|
||||
this.syncBeatOffsetControls();
|
||||
this.waveformButton = this.root.querySelector('[data-action="waveform"]');
|
||||
@@ -648,6 +670,9 @@ class BeatPromptSequencer {
|
||||
this.saveViewState();
|
||||
this.scheduleDraw();
|
||||
});
|
||||
this.controls.beatGridDensity.addEventListener("change", () => {
|
||||
this.setBeatGridDensity(this.controls.beatGridDensity.value);
|
||||
});
|
||||
|
||||
this.root.querySelector('[data-action="zoom-out"]').addEventListener("click", () => this.zoom(1.5));
|
||||
this.root.querySelector('[data-action="zoom-in"]').addEventListener("click", () => this.zoom(0.65));
|
||||
@@ -756,6 +781,7 @@ class BeatPromptSequencer {
|
||||
bind(this.widgets.halfTime, () => this.scheduleAnalysis());
|
||||
bind(this.widgets.beatOffset, (value) => this.setBeatOffset(value, false));
|
||||
bind(this.widgets.analysisSource, () => this.scheduleAnalysis());
|
||||
bind(this.widgets.beatGridDensity, (value) => this.setBeatGridDensity(value, false));
|
||||
bind(this.widgets.defaultFadeIn, () => this.markDirty());
|
||||
bind(this.widgets.defaultFadeOut, () => this.markDirty());
|
||||
bind(this.widgets.curve, () => this.markDirty());
|
||||
@@ -786,17 +812,43 @@ class BeatPromptSequencer {
|
||||
return shifted.filter((value, index) => index === 0 || Math.abs(value - shifted[index - 1]) > EPSILON);
|
||||
}
|
||||
|
||||
gridBeatTimes() {
|
||||
const values = this.beatData?.baseBeatTimes || [];
|
||||
if (this.beatGridDensity() === "every_2_beats") {
|
||||
return values.filter((_, index) => index % 2 === 0);
|
||||
}
|
||||
if (this.beatGridDensity() !== "half_beat") return values;
|
||||
const result = [];
|
||||
for (let index = 0; index < values.length; index++) {
|
||||
result.push(values[index]);
|
||||
if (index + 1 < values.length) {
|
||||
result.push((values[index] + values[index + 1]) / 2);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
applyBeatOffset() {
|
||||
this.syncBeatOffsetControls();
|
||||
const density = this.beatGridDensity();
|
||||
if (this.widgets.beatGridDensity?.value !== density) {
|
||||
this.widgets.beatGridDensity.value = density;
|
||||
}
|
||||
if (this.controls?.beatGridDensity) {
|
||||
this.controls.beatGridDensity.value = density;
|
||||
}
|
||||
if (!this.beatData) {
|
||||
this.scheduleDraw();
|
||||
return;
|
||||
}
|
||||
this.beatData.beatTimes = this.shiftedMarkerTimes(this.beatData.baseBeatTimes);
|
||||
const bpmScale = density === "every_2_beats" ? 0.5 : density === "half_beat" ? 2 : 1;
|
||||
this.beatData.beatTimes = this.shiftedMarkerTimes(this.gridBeatTimes());
|
||||
this.beatData.detectedBeatTimes = this.shiftedMarkerTimes(
|
||||
this.beatData.baseDetectedBeatTimes,
|
||||
);
|
||||
this.beatData.beatOffsetMs = this.beatOffsetMs();
|
||||
this.beatData.beatGridDensity = density;
|
||||
this.beatData.gridBpm = finiteNumber(this.beatData.bpm) * bpmScale;
|
||||
this.refreshBeatStatus();
|
||||
this.scheduleDraw();
|
||||
}
|
||||
@@ -810,6 +862,16 @@ class BeatPromptSequencer {
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
setBeatGridDensity(value, updateWidget = true) {
|
||||
const density = value in GRID_DENSITY_LABELS ? value : "every_beat";
|
||||
if (this.widgets.beatGridDensity &&
|
||||
(updateWidget || this.widgets.beatGridDensity.value !== density)) {
|
||||
this.widgets.beatGridDensity.value = density;
|
||||
}
|
||||
this.applyBeatOffset();
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
saveViewState() {
|
||||
this.node.properties = this.node.properties || {};
|
||||
const savedBeatData = this.beatData ? { ...this.beatData, waveformPreview: null } : null;
|
||||
@@ -1000,6 +1062,8 @@ class BeatPromptSequencer {
|
||||
);
|
||||
this.beatData = {
|
||||
bpm: finiteNumber(payload.bpm),
|
||||
gridBpm: finiteNumber(payload.grid_bpm, payload.bpm),
|
||||
beatGridDensity: payload.beat_grid_density || this.beatGridDensity(),
|
||||
baseBeatTimes: (payload.base_beat_times || payloadBeatTimes.map(
|
||||
(value) => value - payloadOffset,
|
||||
)).map((value) => finiteNumber(value)),
|
||||
@@ -1323,7 +1387,8 @@ class BeatPromptSequencer {
|
||||
const onsets = this.beatData.onsetTimes?.length || 0;
|
||||
const offset = this.beatOffsetMs();
|
||||
const offsetText = offset ? ` · offset ${offset > 0 ? "+" : ""}${offset} ms` : "";
|
||||
const text = `${finiteNumber(this.beatData.bpm).toFixed(2)} BPM · ${count} grid · ` +
|
||||
const density = GRID_DENSITY_LABELS[this.beatGridDensity()];
|
||||
const text = `${finiteNumber(this.beatData.gridBpm, this.beatData.bpm).toFixed(2)} grid BPM · ${density} · ${count} grid · ` +
|
||||
`${detected} detected · ${onsets} onsets · ${finiteNumber(this.beatData.audioDuration).toFixed(2)} sec` +
|
||||
offsetText;
|
||||
if (this.dataFresh) {
|
||||
@@ -1546,6 +1611,8 @@ class BeatPromptSequencer {
|
||||
|
||||
addClipAtPointer(event) {
|
||||
if (this.migrationPending) return;
|
||||
const { y } = this.eventPosition(event);
|
||||
if (y >= BEAT_LANE_TOP && y <= BEAT_LANE_BOTTOM) return;
|
||||
this.addClip(this.frameAtEvent(event));
|
||||
}
|
||||
|
||||
@@ -1651,6 +1718,17 @@ class BeatPromptSequencer {
|
||||
return null;
|
||||
}
|
||||
|
||||
hitTestBeatGrid(x, y) {
|
||||
const right = this.canvas.clientWidth - TIMELINE_RIGHT;
|
||||
return Boolean(
|
||||
this.beatData?.baseBeatTimes?.length &&
|
||||
x >= TIMELINE_LEFT &&
|
||||
x <= right &&
|
||||
y >= BEAT_LANE_TOP &&
|
||||
y <= BEAT_LANE_BOTTOM
|
||||
);
|
||||
}
|
||||
|
||||
updateTrimDrag(x) {
|
||||
const frame = this.sourceFrameAtX(x);
|
||||
const original = this.drag.original;
|
||||
@@ -1670,6 +1748,19 @@ class BeatPromptSequencer {
|
||||
onPointerDown(event) {
|
||||
this.root.focus({ preventScroll: true });
|
||||
const { x, y } = this.eventPosition(event);
|
||||
if (this.hitTestBeatGrid(x, y)) {
|
||||
this.drag = {
|
||||
type: "beat-grid",
|
||||
pointerStartX: x,
|
||||
pointerStartY: y,
|
||||
originalOffset: this.beatOffsetMs(),
|
||||
active: false,
|
||||
};
|
||||
this.canvas.setPointerCapture(event.pointerId);
|
||||
this.canvas.style.cursor = "grabbing";
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const trimHit = this.hitTestTrim(x, y);
|
||||
if (trimHit) {
|
||||
this.drag = {
|
||||
@@ -1773,9 +1864,27 @@ class BeatPromptSequencer {
|
||||
this.scheduleDraw();
|
||||
}
|
||||
|
||||
updateBeatGridDrag(x) {
|
||||
const right = Math.max(TIMELINE_LEFT + 1, this.canvas.clientWidth - TIMELINE_RIGHT);
|
||||
const pixels = right - TIMELINE_LEFT;
|
||||
const frames = (x - this.drag.pointerStartX) / pixels * (this.viewEnd - this.viewStart);
|
||||
this.setBeatOffset(this.drag.originalOffset + frames / this.fps() * 1000);
|
||||
}
|
||||
|
||||
onPointerMove(event) {
|
||||
const { x, y } = this.eventPosition(event);
|
||||
this.hover = { x, y };
|
||||
if (this.drag?.type === "beat-grid") {
|
||||
if (!this.drag.active) {
|
||||
const distance = Math.hypot(x - this.drag.pointerStartX, y - this.drag.pointerStartY);
|
||||
if (distance < 3) return;
|
||||
this.drag.active = true;
|
||||
}
|
||||
this.canvas.style.cursor = "grabbing";
|
||||
this.updateBeatGridDrag(x);
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (this.drag?.type === "trim-start" || this.drag?.type === "trim-end") {
|
||||
if (!this.drag.active) {
|
||||
const distance = Math.hypot(x - this.drag.pointerStartX, y - this.drag.pointerStartY);
|
||||
@@ -1788,9 +1897,12 @@ class BeatPromptSequencer {
|
||||
return;
|
||||
}
|
||||
if (!this.drag || !this.selectedClip()) {
|
||||
const gridHit = this.hitTestBeatGrid(x, y);
|
||||
const trimHit = this.hitTestTrim(x, y);
|
||||
const hit = this.hitTest(x, y);
|
||||
this.canvas.style.cursor = trimHit
|
||||
this.canvas.style.cursor = gridHit
|
||||
? "grab"
|
||||
: trimHit
|
||||
? "ew-resize"
|
||||
: hit
|
||||
? hit.type === "move" ? "grab" : "ew-resize"
|
||||
@@ -1814,6 +1926,7 @@ class BeatPromptSequencer {
|
||||
onPointerUp(event) {
|
||||
if (!this.drag) return;
|
||||
const trimChanged = this.drag.type === "trim-start" || this.drag.type === "trim-end";
|
||||
const gridChanged = this.drag.type === "beat-grid";
|
||||
const changed = this.drag.active;
|
||||
this.drag = null;
|
||||
this.snapGuideFrame = null;
|
||||
@@ -1823,6 +1936,8 @@ class BeatPromptSequencer {
|
||||
if (trimChanged) {
|
||||
this.zoomToFit(false);
|
||||
this.scheduleAnalysis(0);
|
||||
} else if (gridChanged) {
|
||||
this.saveViewState();
|
||||
} else {
|
||||
this.serialize();
|
||||
this.clearError();
|
||||
@@ -2101,42 +2216,48 @@ class BeatPromptSequencer {
|
||||
ctx.fillText(text, boxX + 6, top + 13);
|
||||
}
|
||||
|
||||
drawBeatLane(ctx, width, waveformBottom = null) {
|
||||
drawBeatLane(ctx, width, trackBottom) {
|
||||
const right = width - TIMELINE_RIGHT;
|
||||
ctx.fillStyle = "#15151a";
|
||||
ctx.fillRect(TIMELINE_LEFT, 31, right - TIMELINE_LEFT, 26);
|
||||
ctx.fillStyle = "#71717a";
|
||||
ctx.fillRect(TIMELINE_LEFT, BEAT_LANE_TOP, right - TIMELINE_LEFT, BEAT_LANE_BOTTOM - BEAT_LANE_TOP);
|
||||
ctx.fillStyle = "#67e8f9";
|
||||
ctx.font = "8px Inter, sans-serif";
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("MARKERS", TIMELINE_LEFT - 5, 44);
|
||||
ctx.fillText("BEAT GRID", TIMELINE_LEFT - 5, 44);
|
||||
|
||||
const frames = this.beatFrames();
|
||||
const visibleFrames = frames.filter((frame) => frame >= this.viewStart && frame <= this.viewEnd);
|
||||
const labelEveryBeat = visibleFrames.length < 2 ||
|
||||
Math.abs(this.frameToX(visibleFrames[1], width) - this.frameToX(visibleFrames[0], width)) >= 22;
|
||||
let hovered = null;
|
||||
for (let index = 0; index < frames.length; index++) {
|
||||
const frame = frames[index];
|
||||
if (frame < this.viewStart || frame > this.viewEnd) continue;
|
||||
const x = this.frameToX(frame, width);
|
||||
const accent = index % 4 === 0;
|
||||
ctx.strokeStyle = "#22d3ee";
|
||||
ctx.globalAlpha = 0.72;
|
||||
ctx.lineWidth = accent ? 1.5 : 1;
|
||||
ctx.globalAlpha = accent ? 0.42 : 0.24;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 0.5, 42);
|
||||
ctx.lineTo(x + 0.5, 56);
|
||||
ctx.moveTo(x + 0.5, BEAT_LANE_TOP);
|
||||
ctx.lineTo(x + 0.5, trackBottom);
|
||||
ctx.stroke();
|
||||
if (waveformBottom != null) {
|
||||
ctx.globalAlpha = 0.16;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 0.5, 57);
|
||||
ctx.lineTo(x + 0.5, waveformBottom);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = "#67e8f9";
|
||||
ctx.globalAlpha = 0.72;
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, 40, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
if (labelEveryBeat || accent) {
|
||||
ctx.font = "7px Inter, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(String(index + 1), x, 47);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
if (this.hover?.y >= 31 && this.hover?.y <= 58 && Math.abs(this.hover.x - x) <= 6) {
|
||||
if (this.hover?.y >= BEAT_LANE_TOP && this.hover?.y <= BEAT_LANE_BOTTOM &&
|
||||
Math.abs(this.hover.x - x) <= 6) {
|
||||
hovered = { index, frame, x };
|
||||
}
|
||||
}
|
||||
@@ -2160,7 +2281,7 @@ class BeatPromptSequencer {
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (hovered) {
|
||||
const text = `Beat ${hovered.index} · frame ${hovered.frame} · ${formatClock(hovered.frame / this.fps())}`;
|
||||
const text = `Beat ${hovered.index + 1} · frame ${hovered.frame} · ${formatClock(hovered.frame / this.fps())} · drag to align`;
|
||||
ctx.font = "9px Inter, sans-serif";
|
||||
const boxWidth = ctx.measureText(text).width + 12;
|
||||
const boxX = clamp(hovered.x - boxWidth / 2, TIMELINE_LEFT, right - boxWidth);
|
||||
@@ -2215,12 +2336,14 @@ class BeatPromptSequencer {
|
||||
for (let frame = firstMinor; frame <= this.viewEnd + EPSILON; frame += minor) {
|
||||
const x = this.frameToX(frame, cssWidth);
|
||||
const major = frame % step === 0;
|
||||
ctx.strokeStyle = major ? "#34343a" : "#27272c";
|
||||
ctx.strokeStyle = major ? "#2b2b30" : "#202024";
|
||||
ctx.globalAlpha = major ? 0.72 : 0.48;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 0.5, trackTop);
|
||||
ctx.lineTo(x + 0.5, trackBottom);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const firstTick = Math.ceil(this.viewStart / step) * step;
|
||||
ctx.font = "9px Inter, sans-serif";
|
||||
@@ -2235,8 +2358,6 @@ class BeatPromptSequencer {
|
||||
ctx.textAlign = "right";
|
||||
ctx.fillText("FRAMES", TIMELINE_LEFT - 5, 7);
|
||||
|
||||
this.drawBeatLane(ctx, cssWidth, this.waveformVisible ? waveformBottom : null);
|
||||
|
||||
this.clipRects = [];
|
||||
for (let index = 0; index < this.clips.length; index++) {
|
||||
const clip = this.clips[index];
|
||||
@@ -2311,6 +2432,8 @@ class BeatPromptSequencer {
|
||||
this.clipRects.push({ index, x, y, width, height, fadeInX, fadeOutX });
|
||||
}
|
||||
|
||||
this.drawBeatLane(ctx, cssWidth, trackBottom);
|
||||
|
||||
if (this.snapGuideFrame != null && this.snapGuideFrame >= this.viewStart && this.snapGuideFrame <= this.viewEnd) {
|
||||
const x = this.frameToX(this.snapGuideFrame, cssWidth);
|
||||
ctx.strokeStyle = "#f0abfc";
|
||||
@@ -2386,12 +2509,14 @@ app.registerExtension({
|
||||
halfTime: findWidget(node, "half_time"),
|
||||
beatOffset: findWidget(node, "beat_offset_ms"),
|
||||
analysisSource: findWidget(node, "analysis_source"),
|
||||
beatGridDensity: findWidget(node, "beat_grid_density"),
|
||||
};
|
||||
const hiddenWidgets = [
|
||||
widgets.timeline,
|
||||
widgets.timeUnit,
|
||||
widgets.trimStartFrame,
|
||||
widgets.beatOffset,
|
||||
widgets.beatGridDensity,
|
||||
];
|
||||
for (const widget of hiddenWidgets) hideWidget(widget);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user