Cache H3 prompt analysis helpers

This commit is contained in:
2026-08-25 19:28:51 +00:00
parent d9de0243fc
commit 27d1b76cd5
2 changed files with 217 additions and 50 deletions
+80 -50
View File
@@ -38,8 +38,9 @@ Verified against ComfyUI core (comfy_extras/nodes_minimax_h3.py, model_base.py,
ldm/minimax/model.py, text_encoders/minimax.py, sd.py).
"""
import gc
import json
import gc
from functools import lru_cache
import json
import logging
import math
import os
@@ -1617,19 +1618,22 @@ def compose_persistent(body, active, anchor_id, removed=None, departed=None,
return (count_prefix + out).strip()
def extract_wardrobe(body):
def extract_wardrobe(body):
"""Pull a 'wardrobe: ...' directive line out of a beat body. Returns
(clean_body, wardrobe_or_None). The directive is a whole line starting with
'wardrobe:' (case-insensitive), placed INSIDE a beat (not as its own blank-
line-separated paragraph, which would become its own shot). It's removed
from the body so the literal 'wardrobe:' text isn't stamped as an action."""
kept, wardrobe = [], None
for ln in body.split("\n"):
if re.match(r"\s*wardrobe\s*:", ln, re.I):
wardrobe = ln.split(":", 1)[1].strip()
else:
kept.append(ln)
return "\n".join(kept).strip(), wardrobe
for ln in body.split("\n"):
if re.match(r"\s*wardrobe\s*:", ln, re.I):
wardrobe = ln.split(":", 1)[1].strip()
else:
kept.append(ln)
return "\n".join(kept).strip(), wardrobe
extract_wardrobe = lru_cache(maxsize=2048)(extract_wardrobe)
# --- anchor hazards ---------------------------------------------------------
@@ -2038,7 +2042,7 @@ _SPOKEN_CUE = re.compile(
re.I)
def has_speech(body):
def has_speech(body):
"""True only if a beat contains ACTUAL scripted speech -- double-quoted words
or an explicit <d>...</d> tag. Bare speech VERBS ('calls out', 'tells', 'says'
with no quoted line) deliberately do NOT count: unscripted speech is exactly
@@ -2062,10 +2066,13 @@ def has_speech(body):
lead = body[max(0, m.start() - 60):m.start()].lower()
written = [x.end() for x in _WRITTEN_CUE.finditer(lead)]
spoken = [x.end() for x in _SPOKEN_CUE.finditer(lead)]
if written and (not spoken or written[-1] > spoken[-1]):
continue # printed in the scene, nobody said it
return True
return False
if written and (not spoken or written[-1] > spoken[-1]):
continue # printed in the scene, nobody said it
return True
return False
has_speech = lru_cache(maxsize=2048)(has_speech)
def _spoken_quotes(body):
@@ -2709,15 +2716,18 @@ def removed_phrase_items(body, anchor_id):
return out
def extract_directive(body, key):
def extract_directive(body, key):
"""Pull a '<key>: ...' line out of a beat body. Returns (clean_body, value|None)."""
kept, val = [], None
for ln in body.split("\n"):
if re.match(r"\s*" + key + r"\s*:", ln, re.I):
val = ln.split(":", 1)[1].strip()
else:
kept.append(ln)
return "\n".join(kept).strip(), val
for ln in body.split("\n"):
if re.match(r"\s*" + key + r"\s*:", ln, re.I):
val = ln.split(":", 1)[1].strip()
else:
kept.append(ln)
return "\n".join(kept).strip(), val
extract_directive = lru_cache(maxsize=4096)(extract_directive)
# "walks out OF THE BARN" is emerging INTO the scene, not leaving it -- and a false
@@ -2860,7 +2870,7 @@ _CLAUSE_SPLIT = (r"(?:[.!?;]+|,?\s+(?:and then|then|and|before|after|while|as|un
r"|,\s+(?=[a-z]+ing\b))")
def action_clauses(beat):
def action_clauses(beat):
"""How many distinct staged actions a beat contains.
"takes off her red jacket and drops it on the workbench" is two; "walks the
@@ -2869,49 +2879,66 @@ def action_clauses(beat):
body, _ = extract_wardrobe((beat or "").strip())
body = re.sub(r'["“][^"”]*["”]', " ", body)
body = " ".join(ln for ln in body.splitlines() if not is_directive_line(ln))
parts = [p.strip() for p in re.split(_CLAUSE_SPLIT, body) if p and p.strip()]
# A fragment of one word is a leftover ("it", "her"), not an action of its own.
return sum(1 for p in parts if len(p.split()) >= 2)
parts = [p.strip() for p in re.split(_CLAUSE_SPLIT, body) if p and p.strip()]
# A fragment of one word is a leftover ("it", "her"), not an action of its own.
return sum(1 for p in parts if len(p.split()) >= 2)
action_clauses = lru_cache(maxsize=2048)(action_clauses)
def estimate_beat_seconds(beat):
def estimate_beat_seconds(beat):
"""Screen time this beat needs, from its own content. 0.0 when it has none.
Action and dialogue OVERLAP rather than add -- people talk while they move --
so the estimate is the larger of the two, not their sum."""
n = action_clauses(beat)
action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * n) if n else 0.0
return max(action, dialogue_seconds(beat))
n = action_clauses(beat)
action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * n) if n else 0.0
return max(action, dialogue_seconds(beat))
estimate_beat_seconds = lru_cache(maxsize=2048)(estimate_beat_seconds)
@lru_cache(maxsize=2048)
def _dialogue_spans_cached(beat):
"""Word count of each double-quoted span in a beat, in order. Length of the
returned list is the number of speaking TURNS -- the multi-character case."""
body, _ = extract_wardrobe((beat or "").strip())
return tuple(len(q.split()) for q in re.findall(r'["\u201c]([^"\u201d]+)["\u201d]', body) if q.split())
def dialogue_spans(beat):
return list(_dialogue_spans_cached(beat))
def dialogue_spans(beat):
"""Word count of each double-quoted span in a beat, in order. Length of the
returned list is the number of speaking TURNS -- the multi-character case."""
body, _ = extract_wardrobe((beat or "").strip())
return [len(q.split()) for q in re.findall(r'["\u201c]([^"\u201d]+)["\u201d]', body) if q.split()]
def dialogue_words(beat):
"""Words inside double quotes in a beat -- the only speech H3 actually renders."""
return sum(_dialogue_spans_cached(beat))
dialogue_words = lru_cache(maxsize=2048)(dialogue_words)
def dialogue_words(beat):
"""Words inside double quotes in a beat -- the only speech H3 actually renders."""
return sum(dialogue_spans(beat))
def dialogue_seconds(beat, pad=True):
def dialogue_seconds(beat, pad=True):
"""Screen time this beat's dialogue needs, 0.0 when the beat has none.
Counts every turn, so a two-character exchange is sized from the WHOLE
exchange plus a gap between turns -- not from the longest single line.
`pad` controls only the head/tail air; turn gaps are always counted because
they are time the shot genuinely has to contain."""
spans = dialogue_spans(beat)
if not spans:
return 0.0
return (sum(spans) / WORDS_PER_SEC
+ TURN_GAP_SEC * (len(spans) - 1)
+ (SPEECH_PAD_SEC if pad else 0.0))
spans = _dialogue_spans_cached(beat)
if not spans:
return 0.0
return (sum(spans) / WORDS_PER_SEC
+ TURN_GAP_SEC * (len(spans) - 1)
+ (SPEECH_PAD_SEC if pad else 0.0))
dialogue_seconds = lru_cache(maxsize=4096)(dialogue_seconds)
def beat_seconds_directive(beat):
def beat_seconds_directive(beat):
"""Explicit per-beat length: a 'seconds: 8' (or 'duration: 8') line in the beat.
Returns the float, or None when the beat doesn't set one."""
for key in ("seconds", "duration"):
@@ -2923,9 +2950,12 @@ def beat_seconds_directive(beat):
v = float(m.group(1))
except ValueError:
continue
if v > 0:
return v
return None
if v > 0:
return v
return None
beat_seconds_directive = lru_cache(maxsize=2048)(beat_seconds_directive)
def plan_beat_frames(beats, fps, budget, per_beat=True):