diff --git a/dumas_h3_longvideos.py b/dumas_h3_longvideos.py index 5bced8a..f4ae3c5 100644 --- a/dumas_h3_longvideos.py +++ b/dumas_h3_longvideos.py @@ -312,44 +312,35 @@ def is_directive_line(line): return bool(re.match(r"\s*(" + "|".join(DIRECTIVE_KEYS) + r")\s*:", line or "", re.I)) -def expand_beats(paras, mode="auto"): - """Turn the prompt's beat PARAGRAPHS into the final beat list. Returns - (beats, note). - - Beats are separated by a BLANK line (or a '##' line). That is unambiguous, but - it is also the single easiest thing to get wrong in a textarea: six beats typed - on six consecutive lines are one paragraph, so they render as ONE shot with six - actions crammed into it -- which reads as characters moving at triple speed, not - as a splitting problem. - - mode: - 'auto' -- blank lines first; any paragraph still holding more than one - content line is then split one beat per line, and says so. - 'each line' -- every content line is its own beat. Same result as 'auto'; kept - so the intent can be stated explicitly. - - There is deliberately no strict blank-lines-only mode any more. It was the ONE - setting that could silently lose beats: six beats typed as two blocks of three - rendered as two shots, with no note to say why, because the split note is only - written when a paragraph is actually split. Nothing else on the node can change - the beat count, so removing that option removes the whole failure class. A - workflow that still stores 'blank line' falls through to 'auto' below. - - Directive lines ('wardrobe:', 'seconds:', 'exit:' ...) are never beats of their - own: they attach to the next content line, or to the previous beat if they - trail the paragraph.""" - # Any unrecognized mode means AUTO, never "do nothing". An earlier version fell - # through an if/elif with no else and silently DROPPED every multi-line paragraph - # -- six beats arrived as two shots with four beats simply gone. A stale value on - # this widget (including 'blank line' from a workflow saved before it was removed) - # is enough to trigger it, so the safe branch has to be the default. - if mode not in ("auto", "each line"): - mode = "auto" - out, split_from = [], 0 - for p in paras: - lines = [ln for ln in (p or "").splitlines() if ln.strip()] - content = [ln for ln in lines if not is_directive_line(ln)] - if len(content) <= 1: +def expand_beats(paras, mode="auto"): + """Turn the prompt's beat PARAGRAPHS into the final beat list. Returns + (beats, note). + + Beats are separated by a BLANK line (or a '##' line). That is unambiguous, but + it is also easy to misread once a dedicated beat editor exists: inside the + beat editor, one textbox is one beat, so plain newlines inside that textbox + should stay inside the same beat rather than quietly creating new shots. + + mode: + 'auto' -- preserve each paragraph as one beat. + 'each line' -- every content line is its own beat, with directive lines + attaching to the beat they configure. + + Directive lines ('wardrobe:', 'seconds:', 'exit:' ...) are never beats of their + own: they attach to the next content line, or to the previous beat if they + trail the paragraph.""" + # Any unrecognized mode means AUTO, never "do nothing". Older workflows may + # still store removed values such as 'blank line'; those should preserve whole + # paragraphs instead of unexpectedly splitting every newline into a beat. + if mode not in ("auto", "each line"): + mode = "auto" + if mode == "auto": + return [p.strip() for p in (paras or []) if str(p or "").strip()], "" + out, split_from = [], 0 + for p in paras: + lines = [ln for ln in (p or "").splitlines() if ln.strip()] + content = [ln for ln in lines if not is_directive_line(ln)] + if len(content) <= 1: out.append(p) else: split_from += 1 @@ -368,13 +359,12 @@ def expand_beats(paras, mode="auto"): if out: out[-1] = out[-1] + NL + NL.join(pending) else: - out.append(NL.join(pending)) - note = "" - if split_from and mode == "auto": - note = (f"{split_from} paragraph(s) held several lines and were split one beat per LINE " - f"-> {len(out)} beats. Separate beats with a BLANK line (or a '##' line) to control " - f"this yourself") - return out, note + out.append(NL.join(pending)) + note = "" + if split_from: + note = (f"beat_split '{mode}' split {split_from} multi-line paragraph(s) into " + f"{len(out)} beats") + return out, note def _garment_side(side): @@ -6153,18 +6143,13 @@ class H3LongVideos: "identity fidelity, but reference rows are re-attended EVERY step of " "EVERY ref-conditioned shot, so on a long chain it is several times " "slower. Neither ever upscales a small reference."}), - "beat_split": (["auto", "each line"], {"default": "auto", - "tooltip": "How the prompt box becomes beats. Beats are meant to be separated by a " - "BLANK line (or a '##' line) -- but six beats typed on six consecutive " - "lines are ONE paragraph, so they would render as one shot with six actions " - "crammed into it, which looks like everyone is moving at triple speed. " - "auto (default): blank lines first, then any paragraph still holding " - "several lines is split one beat per LINE, and the info output says so. " - "'each line': every line is its own beat -- same result, stated explicitly. " - "Neither can lose a beat. (The old strict 'blank line' option was REMOVED: " - "it was the only setting that could silently collapse beats, and a stored " - "value of it now reads as 'auto'.) Directive lines (wardrobe:, seconds:, exit:) " - "are never beats -- they attach to the beat that follows them."}), + "beat_split": (["auto", "each line"], {"default": "auto", + "tooltip": "How the prompt box becomes beats. Beats are separated by a BLANK line " + "(or a '##' line). auto (default): each paragraph stays one beat, so " + "plain newlines inside a beat remain part of that beat. 'each line': every " + "content line becomes its own beat, with directive lines (wardrobe:, " + "seconds:, exit:, etc.) attaching to the beat they configure. Older " + "removed values such as 'blank line' now behave like 'auto'."}), "anchor_override": ("STRING", {"multiline": True, "forceInput": True, "default": "", "tooltip": "Set the persistent look explicitly instead of using the first paragraph. " "When this is filled in, EVERY paragraph of the prompt box is a beat/shot -- " diff --git a/tests/test_dumas_h3_longvideos.py b/tests/test_dumas_h3_longvideos.py index 3e80299..1fb9abe 100644 --- a/tests/test_dumas_h3_longvideos.py +++ b/tests/test_dumas_h3_longvideos.py @@ -160,6 +160,45 @@ class DumasH3LongVideosHelperTests(unittest.TestCase): "keyframe carry", ) + def test_expand_beats_auto_preserves_multiline_paragraph_as_one_beat(self): + beats, note = self.module.expand_beats( + ["wardrobe: Maya = red jacket\nMaya enters the room.\nShe sits at the table."], + "auto", + ) + + self.assertEqual( + beats, + ["wardrobe: Maya = red jacket\nMaya enters the room.\nShe sits at the table."], + ) + self.assertEqual(note, "") + + def test_expand_beats_legacy_blank_line_value_falls_back_to_auto(self): + beats, note = self.module.expand_beats( + ["Maya enters the room.\nShe sits at the table."], + "blank line", + ) + + self.assertEqual( + beats, + ["Maya enters the room.\nShe sits at the table."], + ) + self.assertEqual(note, "") + + def test_expand_beats_each_line_still_splits_multiline_paragraphs(self): + beats, note = self.module.expand_beats( + ["wardrobe: Maya = red jacket\nMaya enters the room.\nseconds: 8\nShe sits at the table."], + "each line", + ) + + self.assertEqual( + beats, + [ + "wardrobe: Maya = red jacket\nMaya enters the room.", + "seconds: 8\nShe sits at the table.", + ], + ) + self.assertIn("beat_split 'each line' split 1 multi-line paragraph(s) into 2 beats", note) + def test_timing_summary_reports_retry_and_bucket_totals(self): note = self.module._format_timing_note([ {