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:
@@ -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);
|
camera moves, and deltas that escalate with the music. `librosa` is optional (BPM/beats);
|
||||||
without it you still get the energy envelope + segments.
|
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
|
## Performance / speed
|
||||||
|
|
||||||
This node runs models through **transformers `.generate()`** — the simplest path, but the
|
This node runs models through **transformers `.generate()`** — the simplest path, but the
|
||||||
|
|||||||
+53
-9
@@ -19,11 +19,49 @@ with it you also get tempo (BPM) and beat times.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from PIL import Image, ImageDraw
|
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]:
|
def _to_mono(audio) -> tuple[np.ndarray, int]:
|
||||||
"""ComfyUI AUDIO dict -> (mono float32 samples, sample_rate)."""
|
"""ComfyUI AUDIO dict -> (mono float32 samples, sample_rate)."""
|
||||||
wf = audio["waveform"]
|
wf = audio["waveform"]
|
||||||
@@ -108,12 +146,15 @@ def _render(rms_n, times, duration, beats, bounds, segs):
|
|||||||
for bd in bounds: # segment boundaries (white)
|
for bd in bounds: # segment boundaries (white)
|
||||||
d.line([(X(bd), 0), (X(bd), H)], fill=(240, 240, 240), width=1)
|
d.line([(X(bd), 0), (X(bd), H)], fill=(240, 240, 240), width=1)
|
||||||
for s in segs:
|
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
|
arr = np.asarray(img, dtype=np.float32) / 255.0
|
||||||
return torch.from_numpy(arr)[None, ...] # [1, H, W, 3]
|
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:",
|
lines = ["AUDIO GUIDE — align the video beats to this audio:",
|
||||||
f"- duration: {duration:.2f}s | sample_rate: {sr} Hz"]
|
f"- duration: {duration:.2f}s | sample_rate: {sr} Hz"]
|
||||||
if bpm:
|
if bpm:
|
||||||
@@ -121,21 +162,23 @@ def _summary(duration, sr, bpm, beats, segs, notes):
|
|||||||
if beats:
|
if beats:
|
||||||
preview = ", ".join(f"{b:.2f}" for b in beats[:24])
|
preview = ", ".join(f"{b:.2f}" for b in beats[:24])
|
||||||
lines.append(f"- beat times (s): {preview}{' ...' if len(beats) > 24 else ''}")
|
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:
|
for s in segs:
|
||||||
peak = f", energy peak @ {s['peak_s']}s" if s["energy"] == "high" else ""
|
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(
|
lines.append(
|
||||||
f" seg{s['segment']}: {s['start_s']}–{s['start_s'] + s['duration_s']:.2f}s, "
|
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['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("")
|
||||||
lines.append("Guidance: higher energy -> faster motion and bigger camera moves; "
|
lines.append("Guidance: higher energy -> faster motion and bigger camera moves; "
|
||||||
"lower energy -> slower, settle. Put escalation on rising energy and the "
|
"lower energy -> slower, settle. Put escalation on rising energy and the "
|
||||||
"release on the final segment. Snap each beat's frames to 8n+1.")
|
"release on the final segment. Snap each beat's frames to 8n+1. Where a "
|
||||||
if notes.strip():
|
"segment has a NOTE, that instruction OVERRIDES the energy default.")
|
||||||
|
if global_notes:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("USER NOTES (map these onto the matching segments/times):")
|
lines.append("GLOBAL NOTES (apply throughout):")
|
||||||
lines.append(notes.strip())
|
lines.extend(f" - {n}" for n in global_notes)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,8 +205,9 @@ class AudioPromptGuide:
|
|||||||
rms_n, times = _rms_envelope(y, sr)
|
rms_n, times = _rms_envelope(y, sr)
|
||||||
bpm, beats = _tempo_beats(y, sr)
|
bpm, beats = _tempo_beats(y, sr)
|
||||||
segs, bounds = _segments(rms_n, times, duration, fps, max_segments, beats)
|
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)
|
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)
|
return (image, summary)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user