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). ldm/minimax/model.py, text_encoders/minimax.py, sd.py).
""" """
import gc import gc
import json from functools import lru_cache
import json
import logging import logging
import math import math
import os import os
@@ -1617,19 +1618,22 @@ def compose_persistent(body, active, anchor_id, removed=None, departed=None,
return (count_prefix + out).strip() return (count_prefix + out).strip()
def extract_wardrobe(body): def extract_wardrobe(body):
"""Pull a 'wardrobe: ...' directive line out of a beat body. Returns """Pull a 'wardrobe: ...' directive line out of a beat body. Returns
(clean_body, wardrobe_or_None). The directive is a whole line starting with (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- 'wardrobe:' (case-insensitive), placed INSIDE a beat (not as its own blank-
line-separated paragraph, which would become its own shot). It's removed 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.""" from the body so the literal 'wardrobe:' text isn't stamped as an action."""
kept, wardrobe = [], None kept, wardrobe = [], None
for ln in body.split("\n"): for ln in body.split("\n"):
if re.match(r"\s*wardrobe\s*:", ln, re.I): if re.match(r"\s*wardrobe\s*:", ln, re.I):
wardrobe = ln.split(":", 1)[1].strip() wardrobe = ln.split(":", 1)[1].strip()
else: else:
kept.append(ln) kept.append(ln)
return "\n".join(kept).strip(), wardrobe return "\n".join(kept).strip(), wardrobe
extract_wardrobe = lru_cache(maxsize=2048)(extract_wardrobe)
# --- anchor hazards --------------------------------------------------------- # --- anchor hazards ---------------------------------------------------------
@@ -2038,7 +2042,7 @@ _SPOKEN_CUE = re.compile(
re.I) re.I)
def has_speech(body): def has_speech(body):
"""True only if a beat contains ACTUAL scripted speech -- double-quoted words """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' 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 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() lead = body[max(0, m.start() - 60):m.start()].lower()
written = [x.end() for x in _WRITTEN_CUE.finditer(lead)] written = [x.end() for x in _WRITTEN_CUE.finditer(lead)]
spoken = [x.end() for x in _SPOKEN_CUE.finditer(lead)] spoken = [x.end() for x in _SPOKEN_CUE.finditer(lead)]
if written and (not spoken or written[-1] > spoken[-1]): if written and (not spoken or written[-1] > spoken[-1]):
continue # printed in the scene, nobody said it continue # printed in the scene, nobody said it
return True return True
return False return False
has_speech = lru_cache(maxsize=2048)(has_speech)
def _spoken_quotes(body): def _spoken_quotes(body):
@@ -2709,15 +2716,18 @@ def removed_phrase_items(body, anchor_id):
return out 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).""" """Pull a '<key>: ...' line out of a beat body. Returns (clean_body, value|None)."""
kept, val = [], None kept, val = [], None
for ln in body.split("\n"): for ln in body.split("\n"):
if re.match(r"\s*" + key + r"\s*:", ln, re.I): if re.match(r"\s*" + key + r"\s*:", ln, re.I):
val = ln.split(":", 1)[1].strip() val = ln.split(":", 1)[1].strip()
else: else:
kept.append(ln) kept.append(ln)
return "\n".join(kept).strip(), val 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 # "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))") r"|,\s+(?=[a-z]+ing\b))")
def action_clauses(beat): def action_clauses(beat):
"""How many distinct staged actions a beat contains. """How many distinct staged actions a beat contains.
"takes off her red jacket and drops it on the workbench" is two; "walks the "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, _ = extract_wardrobe((beat or "").strip())
body = re.sub(r'["“][^"”]*["”]', " ", body) body = re.sub(r'["“][^"”]*["”]', " ", body)
body = " ".join(ln for ln in body.splitlines() if not is_directive_line(ln)) 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()] 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. # 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) 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. """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 -- Action and dialogue OVERLAP rather than add -- people talk while they move --
so the estimate is the larger of the two, not their sum.""" so the estimate is the larger of the two, not their sum."""
n = action_clauses(beat) n = action_clauses(beat)
action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * n) if n else 0.0 action = (BEAT_BASE_SEC + SECONDS_PER_ACTION * n) if n else 0.0
return max(action, dialogue_seconds(beat)) 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): def dialogue_words(beat):
"""Word count of each double-quoted span in a beat, in order. Length of the """Words inside double quotes in a beat -- the only speech H3 actually renders."""
returned list is the number of speaking TURNS -- the multi-character case.""" return sum(_dialogue_spans_cached(beat))
body, _ = extract_wardrobe((beat or "").strip())
return [len(q.split()) for q in re.findall(r'["\u201c]([^"\u201d]+)["\u201d]', body) if q.split()]
dialogue_words = lru_cache(maxsize=2048)(dialogue_words)
def dialogue_words(beat): def dialogue_seconds(beat, pad=True):
"""Words inside double quotes in a beat -- the only speech H3 actually renders."""
return sum(dialogue_spans(beat))
def dialogue_seconds(beat, pad=True):
"""Screen time this beat's dialogue needs, 0.0 when the beat has none. """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 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. 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 `pad` controls only the head/tail air; turn gaps are always counted because
they are time the shot genuinely has to contain.""" they are time the shot genuinely has to contain."""
spans = dialogue_spans(beat) spans = _dialogue_spans_cached(beat)
if not spans: if not spans:
return 0.0 return 0.0
return (sum(spans) / WORDS_PER_SEC return (sum(spans) / WORDS_PER_SEC
+ TURN_GAP_SEC * (len(spans) - 1) + TURN_GAP_SEC * (len(spans) - 1)
+ (SPEECH_PAD_SEC if pad else 0.0)) + (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. """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.""" Returns the float, or None when the beat doesn't set one."""
for key in ("seconds", "duration"): for key in ("seconds", "duration"):
@@ -2923,9 +2950,12 @@ def beat_seconds_directive(beat):
v = float(m.group(1)) v = float(m.group(1))
except ValueError: except ValueError:
continue continue
if v > 0: if v > 0:
return v return v
return None return None
beat_seconds_directive = lru_cache(maxsize=2048)(beat_seconds_directive)
def plan_beat_frames(beats, fps, budget, per_beat=True): def plan_beat_frames(beats, fps, budget, per_beat=True):
+137
View File
@@ -0,0 +1,137 @@
import importlib
import sys
import types
import unittest
class DumasH3LongVideosHelperTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._saved_modules = {
name: sys.modules.get(name)
for name in (
"torch",
"nodes",
"comfy",
"comfy.utils",
"comfy.samplers",
"comfy.nested_tensor",
"comfy.model_management",
"node_helpers",
"dumas_h3_longvideos",
)
}
fake_torch = types.SimpleNamespace(
cuda=types.SimpleNamespace(OutOfMemoryError=RuntimeError),
float32="float32",
)
fake_nodes = types.SimpleNamespace(common_ksampler=lambda *args, **kwargs: ({},))
fake_comfy_samplers = types.SimpleNamespace(
KSampler=types.SimpleNamespace(SAMPLERS=("res_multistep",), SCHEDULERS=("simple",))
)
fake_comfy_utils = types.SimpleNamespace(ProgressBar=lambda total: None)
fake_mm = types.SimpleNamespace(
current_loaded_models=[],
free_memory=lambda *args, **kwargs: None,
get_torch_device=lambda: "cpu",
soft_empty_cache=lambda *args, **kwargs: None,
unload_all_models=lambda *args, **kwargs: None,
get_free_memory=lambda *args, **kwargs: 0,
get_total_memory=lambda *args, **kwargs: 0,
)
fake_comfy = types.SimpleNamespace(
utils=fake_comfy_utils,
samplers=fake_comfy_samplers,
nested_tensor=types.SimpleNamespace(),
model_management=fake_mm,
)
sys.modules["torch"] = fake_torch
sys.modules["nodes"] = fake_nodes
sys.modules["comfy"] = fake_comfy
sys.modules["comfy.utils"] = fake_comfy_utils
sys.modules["comfy.samplers"] = fake_comfy_samplers
sys.modules["comfy.nested_tensor"] = fake_comfy.nested_tensor
sys.modules["comfy.model_management"] = fake_mm
sys.modules["node_helpers"] = types.SimpleNamespace()
cls.module = importlib.import_module("dumas_h3_longvideos")
@classmethod
def tearDownClass(cls):
for name, module in cls._saved_modules.items():
if module is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = module
def test_extract_wardrobe_is_cached(self):
fn = self.module.extract_wardrobe
fn.cache_clear()
beat = "walks forward\nwardrobe: red jacket, grey shorts\nlooks back"
self.assertEqual(fn(beat), ("walks forward\nlooks back", "red jacket, grey shorts"))
self.assertEqual(fn(beat), ("walks forward\nlooks back", "red jacket, grey shorts"))
self.assertGreater(fn.cache_info().hits, 0)
def test_dialogue_helpers_keep_existing_outputs_and_cache(self):
spans_cache = self.module._dialogue_spans_cached
sec_fn = self.module.dialogue_seconds
words_fn = self.module.dialogue_words
spans_cache.cache_clear()
sec_fn.cache_clear()
words_fn.cache_clear()
beat = 'Mara says, "Open it now." Jon replies, "Do it."'
self.assertEqual(self.module.dialogue_spans(beat), [3, 2])
self.assertEqual(words_fn(beat), 5)
self.assertAlmostEqual(sec_fn(beat), 3.5)
self.assertAlmostEqual(sec_fn(beat, pad=False), 2.5)
self.module.dialogue_spans(beat)
sec_fn(beat)
words_fn(beat)
self.assertGreater(spans_cache.cache_info().hits, 0)
self.assertGreater(sec_fn.cache_info().hits, 0)
self.assertGreater(words_fn.cache_info().hits, 0)
def test_directive_and_estimate_helpers_are_cached(self):
directive_fn = self.module.beat_seconds_directive
estimate_fn = self.module.estimate_beat_seconds
action_fn = self.module.action_clauses
directive_fn.cache_clear()
estimate_fn.cache_clear()
action_fn.cache_clear()
beat = 'seconds: 7.5\nShe opens the hatch and climbs inside.'
self.assertEqual(directive_fn(beat), 7.5)
self.assertEqual(action_fn(beat), 2)
self.assertAlmostEqual(estimate_fn(beat), 7.0)
directive_fn(beat)
action_fn(beat)
estimate_fn(beat)
self.assertGreater(directive_fn.cache_info().hits, 0)
self.assertGreater(action_fn.cache_info().hits, 0)
self.assertGreater(estimate_fn.cache_info().hits, 0)
def test_has_speech_cache_respects_written_text_filter(self):
fn = self.module.has_speech
fn.cache_clear()
written = 'She reads the sign marked "EXIT" and keeps walking.'
spoken = 'She says, "Exit now." and points to the door.'
self.assertFalse(fn(written))
self.assertTrue(fn(spoken))
fn(written)
fn(spoken)
self.assertGreaterEqual(fn.cache_info().hits, 2)
if __name__ == "__main__":
unittest.main()