Audio Prompt Guide: per-segment / per-time / global notes

The notes box now routes each line to a target: 'seg2: fast' (by number, also '2:'
/ 'S2:'), '10s-15s: drop' (by time range / '12.3s:'), or unprefixed = global. Segment
notes are attached to the matching segment in the summary (marked NOTE, overrides the
energy default) and drawn on the waveform image; time notes attach to overlapping
segments (strict end boundary); globals listed separately. README documents the syntax.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 22:09:43 +02:00
co-authored by Claude Opus 4.8
parent 6e6232d4ab
commit 56f1a62616
2 changed files with 62 additions and 9 deletions
+9
View File
@@ -86,6 +86,15 @@ The model then gets the audio's timing/energy + your motion notes and writes bea
camera moves, and deltas that escalate with the music. `librosa` is optional (BPM/beats);
without it you still get the energy envelope + segments.
**Notes can target a segment** (the note overrides that segment's energy default, and is drawn
on the waveform). Syntax in the `notes` box, one per line:
```
seg1: slow dreamy intro # by segment number (also "1:" or "S1:")
10s-15s: explosive drop # by time range (also "12.3s:")
cinematic, moody grade # no prefix = global (applies throughout)
```
## Performance / speed
This node runs models through **transformers `.generate()`** — the simplest path, but the
+53 -9
View File
@@ -19,11 +19,49 @@ with it you also get tempo (BPM) and beat times.
from __future__ import annotations
import re
import numpy as np
import torch
from PIL import Image, ImageDraw
def _split_notes(notes: str):
"""Parse the notes box into per-segment / per-time / global notes. Lines like:
seg2: fast motion (or 2: fast motion / S2: ...) -> segment 2
5s: build (or 0-5s: ... / 12.3s: ...) -> by time
cinematic, moody (no prefix) -> global
Returns (seg_map {num: [notes]}, time_notes [(t0,t1,text)], global_notes [str])."""
seg_map, time_notes, glob = {}, [], []
for raw in notes.splitlines():
line = raw.strip()
if not line:
continue
mt = re.match(r"(?i)^(\d+(?:\.\d+)?)\s*s(?:\s*[-to]+\s*(\d+(?:\.\d+)?)\s*s?)?\s*[:)\-]\s*(.+)$", line)
ms = (re.match(r"(?i)^(?:seg(?:ment)?|s)\s*(\d+)\s*[:)\-]\s*(.+)$", line)
or re.match(r"^(\d+)\s*[:)]\s*(.+)$", line))
if mt:
t0 = float(mt.group(1)); t1 = float(mt.group(2)) if mt.group(2) else t0
time_notes.append((t0, t1, mt.group(3).strip()))
elif ms:
seg_map.setdefault(int(ms.group(1)), []).append(ms.group(2).strip())
else:
glob.append(line)
return seg_map, time_notes, glob
def _attach_notes(segs, notes):
"""Attach the parsed notes to each segment (by number or overlapping time).
Returns the leftover global notes."""
seg_map, time_notes, glob = _split_notes(notes)
for s in segs:
s0, s1 = s["start_s"], s["start_s"] + s["duration_s"]
found = list(seg_map.get(s["segment"], []))
found += [txt for (t0, t1, txt) in time_notes if t0 < s1 and t1 > s0]
s["note"] = "; ".join(found)
return glob
def _to_mono(audio) -> tuple[np.ndarray, int]:
"""ComfyUI AUDIO dict -> (mono float32 samples, sample_rate)."""
wf = audio["waveform"]
@@ -108,12 +146,15 @@ def _render(rms_n, times, duration, beats, bounds, segs):
for bd in bounds: # segment boundaries (white)
d.line([(X(bd), 0), (X(bd), H)], fill=(240, 240, 240), width=1)
for s in segs:
d.text((X(s["start_s"]) + 4, 4), f"S{s['segment']} {s['energy']}", fill=(255, 255, 255))
x = X(s["start_s"]) + 4
d.text((x, 4), f"S{s['segment']} {s['energy']}", fill=(255, 255, 255))
if s.get("note"): # per-segment note (amber)
d.text((x, 18), s["note"][:30], fill=(255, 210, 110))
arr = np.asarray(img, dtype=np.float32) / 255.0
return torch.from_numpy(arr)[None, ...] # [1, H, W, 3]
def _summary(duration, sr, bpm, beats, segs, notes):
def _summary(duration, sr, bpm, beats, segs, global_notes):
lines = ["AUDIO GUIDE — align the video beats to this audio:",
f"- duration: {duration:.2f}s | sample_rate: {sr} Hz"]
if bpm:
@@ -121,21 +162,23 @@ def _summary(duration, sr, bpm, beats, segs, notes):
if beats:
preview = ", ".join(f"{b:.2f}" for b in beats[:24])
lines.append(f"- beat times (s): {preview}{' ...' if len(beats) > 24 else ''}")
lines.append("- suggested segments (use these durations/frames and energy):")
lines.append("- suggested segments (use these durations/frames and energy; honor the NOTE):")
for s in segs:
peak = f", energy peak @ {s['peak_s']}s" if s["energy"] == "high" else ""
note = f" <<< NOTE: {s['note']}" if s.get("note") else ""
lines.append(
f" seg{s['segment']}: {s['start_s']}{s['start_s'] + s['duration_s']:.2f}s, "
f"{s['duration_s']}s, {s['frames']} frames, energy {s['energy'].upper()} "
f"({s['stage_hint']}){peak}")
f"({s['stage_hint']}){peak}{note}")
lines.append("")
lines.append("Guidance: higher energy -> faster motion and bigger camera moves; "
"lower energy -> slower, settle. Put escalation on rising energy and the "
"release on the final segment. Snap each beat's frames to 8n+1.")
if notes.strip():
"release on the final segment. Snap each beat's frames to 8n+1. Where a "
"segment has a NOTE, that instruction OVERRIDES the energy default.")
if global_notes:
lines.append("")
lines.append("USER NOTES (map these onto the matching segments/times):")
lines.append(notes.strip())
lines.append("GLOBAL NOTES (apply throughout):")
lines.extend(f" - {n}" for n in global_notes)
return "\n".join(lines)
@@ -162,8 +205,9 @@ class AudioPromptGuide:
rms_n, times = _rms_envelope(y, sr)
bpm, beats = _tempo_beats(y, sr)
segs, bounds = _segments(rms_n, times, duration, fps, max_segments, beats)
global_notes = _attach_notes(segs, notes) # per-segment / per-time / global
image = _render(rms_n, times, duration, beats, bounds, segs)
summary = _summary(duration, sr, bpm, beats, segs, notes)
summary = _summary(duration, sr, bpm, beats, segs, global_notes)
return (image, summary)