writer: spoken WORDS size the audio, not the number of lines
Browse filesThe length table counts spoken lines, and a line runs from six words to twenty.
Six words is 2.4 s of a 10 s hop. chain_00059 was written one six-word line per
10 s hop -- inside the word band, one line short of the row -- and rendered
17.8%, 10.9% and 6.6% voiced across its three hops, longest continuous voiced
run 0.5 s. Fragments, not speech. The line lint alone would have called it one
line short and left the author no idea how far short that actually was.
`count_beat` returns the spoken words too, and validate does the arithmetic out
loud: "6 spoken words is about 2.4s of speech in a 10s hop. A character written
as talking throughout needs roughly 25." The number the author needs is not the
number of lines.
The floor is half the hop, deliberately lenient: a beat is allowed to be action
as well as talk, and the point is to catch a hop that cannot possibly be filled
rather than to demand wall-to-wall dialogue. A hop with no speech at all says
nothing here -- that is what the line lint is for.
build_user_turn states the same arithmetic on the pinned path, so the draft
starts closer instead of being corrected afterwards.
PROMPTING.md now says outright that a written plan is a strong draft rather
than a finished one, and names the two things to read every time: the `desc` on
each ref, and the spoken words in each beat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014VB4Dw6F79643vySkf19KV
- PROMPTING.md +21 -0
- planner.py +55 -4
- tools/check_planner.py +25 -0
|
@@ -630,3 +630,24 @@ a `shot_plan` and `ref_plan` this node accepts:
|
|
| 630 |
|
| 631 |
The node still validates everything, so a model that gets it wrong is caught
|
| 632 |
rather than obeyed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
|
| 631 |
The node still validates everything, so a model that gets it wrong is caught
|
| 632 |
rather than obeyed.
|
| 633 |
+
|
| 634 |
+
**Treat a written plan as a strong draft, not a finished one.** It will get the
|
| 635 |
+
structure right -- tags, retention tiers, schedules, directives, a beat per hop
|
| 636 |
+
that reads as one continuous take -- and that is the part that is tedious to
|
| 637 |
+
write by hand and easy to get subtly wrong. What it will not reliably get right
|
| 638 |
+
is judgement about your particular pictures and your particular scene. Two
|
| 639 |
+
things to read every time before you queue:
|
| 640 |
+
|
| 641 |
+
- **The `desc` on each ref.** It is written from the photograph and it goes into
|
| 642 |
+
the prompt verbatim as what to carry over, so a wrong one is actively
|
| 643 |
+
harmful -- a wardrobe plate described as "a dark top" when the garment is
|
| 644 |
+
white will fight the picture it came from. Vision quality varies a lot
|
| 645 |
+
between models here.
|
| 646 |
+
- **The spoken words in each beat.** Speech runs about 2.5 words a second, so a
|
| 647 |
+
10 s hop of someone talking needs roughly 25 words inside the quotes. Models
|
| 648 |
+
write one short line and stop. The panel now warns with the arithmetic, and
|
| 649 |
+
the fix is to write the extra sentences yourself.
|
| 650 |
+
|
| 651 |
+
Everything the panel flags is a warning rather than a rejection, because all of
|
| 652 |
+
it is legitimate to want on purpose. Read the warnings, fix what you meant
|
| 653 |
+
differently, and queue.
|
|
@@ -80,12 +80,30 @@ def beat_table():
|
|
| 80 |
return out
|
| 81 |
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
def count_beat(beat):
|
| 84 |
-
"""(words, spoken_lines) for one beat.
|
| 85 |
|
| 86 |
A spoken line is a single-quoted span. The delimiter test is a quote that is
|
| 87 |
NOT between two word characters, because the beats are full of apostrophes
|
| 88 |
that are: "Today's class was absolutely exhausting" is one line, not two.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
"""
|
| 90 |
text = str(beat or "")
|
| 91 |
words = len(text.split())
|
|
@@ -93,7 +111,10 @@ def count_beat(beat):
|
|
| 93 |
delims = [i for i in marks
|
| 94 |
if not (i > 0 and text[i - 1].isalnum()
|
| 95 |
and i + 1 < len(text) and text[i + 1].isalnum())]
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def schema():
|
|
@@ -184,7 +205,7 @@ def validate(shot_text, ref_text, *, hops=None, known_files=None, pinned=None,
|
|
| 184 |
to the person but never retried, because a model asked to fix a lint it
|
| 185 |
disagrees with tends to rewrite the parts that were fine.
|
| 186 |
"""
|
| 187 |
-
from .h3_ref_chain import DURATION_FRAMES
|
| 188 |
|
| 189 |
errors, warnings = [], []
|
| 190 |
|
|
@@ -246,7 +267,7 @@ def validate(shot_text, ref_text, *, hops=None, known_files=None, pinned=None,
|
|
| 246 |
if not row:
|
| 247 |
continue
|
| 248 |
w0, w1, l0, l1 = row
|
| 249 |
-
words, spoken = count_beat((sh or {}).get("beat"))
|
| 250 |
label = str((sh or {}).get("duration") or duration).strip()
|
| 251 |
if words < w0 or words > w1:
|
| 252 |
warnings.append(
|
|
@@ -262,6 +283,20 @@ def validate(shot_text, ref_text, *, hops=None, known_files=None, pinned=None,
|
|
| 262 |
f"{l0}" + (f"-{l1}" if l1 > l0 else "") + ". The line count is "
|
| 263 |
"a floor as well as a ceiling -- speaking seconds with nothing "
|
| 264 |
"assigned come back as invented dialogue.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
# A person with pictures but no continuity text still *renders*, which is
|
| 267 |
# why `refs.check` only warns. The writer is the one place that can fill
|
|
@@ -548,6 +583,22 @@ def build_user_turn(brief, hops, files, pinned=None, duration=None):
|
|
| 548 |
+ (f"{l0}-{l1} spoken lines" if l1 > l0 else
|
| 549 |
f"{l0} spoken line" + ("" if l0 == 1 else "s"))
|
| 550 |
+ ". Count both in every beat before you answer.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
pinned = [p for p in (pinned or []) if isinstance(p, dict)]
|
| 552 |
if pinned:
|
| 553 |
lines += ["",
|
|
|
|
| 80 |
return out
|
| 81 |
|
| 82 |
|
| 83 |
+
# Words per second of ordinary speech. ~150 wpm is the conversational figure
|
| 84 |
+
# and it matches what this model renders: chain_00059 hop 1 carried six spoken
|
| 85 |
+
# words and measured 1.8 s of voiced audio.
|
| 86 |
+
SPEECH_WPS = 2.5
|
| 87 |
+
|
| 88 |
+
# How much of a hop a speaking character should actually be speaking for. Below
|
| 89 |
+
# this there are seconds of someone visibly mid-sentence with nothing assigned,
|
| 90 |
+
# and the model fills them itself -- as fragments (chain_00059: 17.8% voiced,
|
| 91 |
+
# longest run 0.5 s) or as invented dialogue (chain_00048). Half is deliberately
|
| 92 |
+
# lenient: a beat is allowed to be action as well as talk, and the point is to
|
| 93 |
+
# catch a hop that cannot possibly be filled, not to demand wall-to-wall speech.
|
| 94 |
+
SPEECH_MIN_SHARE = 0.5
|
| 95 |
+
|
| 96 |
+
|
| 97 |
def count_beat(beat):
|
| 98 |
+
"""(words, spoken_lines, spoken_words) for one beat.
|
| 99 |
|
| 100 |
A spoken line is a single-quoted span. The delimiter test is a quote that is
|
| 101 |
NOT between two word characters, because the beats are full of apostrophes
|
| 102 |
that are: "Today's class was absolutely exhausting" is one line, not two.
|
| 103 |
+
|
| 104 |
+
`spoken_words` is what is inside those spans. Lines are the wrong unit on
|
| 105 |
+
their own -- a line runs from six words to twenty, and six words is 2.4 s
|
| 106 |
+
of a 10 s hop.
|
| 107 |
"""
|
| 108 |
text = str(beat or "")
|
| 109 |
words = len(text.split())
|
|
|
|
| 111 |
delims = [i for i in marks
|
| 112 |
if not (i > 0 and text[i - 1].isalnum()
|
| 113 |
and i + 1 < len(text) and text[i + 1].isalnum())]
|
| 114 |
+
spoken = 0
|
| 115 |
+
for a, b in zip(delims[0::2], delims[1::2]):
|
| 116 |
+
spoken += len(text[a + 1:b].split())
|
| 117 |
+
return words, len(delims) // 2, spoken
|
| 118 |
|
| 119 |
|
| 120 |
def schema():
|
|
|
|
| 205 |
to the person but never retried, because a model asked to fix a lint it
|
| 206 |
disagrees with tends to rewrite the parts that were fine.
|
| 207 |
"""
|
| 208 |
+
from .h3_ref_chain import DURATION_FRAMES, FPS
|
| 209 |
|
| 210 |
errors, warnings = [], []
|
| 211 |
|
|
|
|
| 267 |
if not row:
|
| 268 |
continue
|
| 269 |
w0, w1, l0, l1 = row
|
| 270 |
+
words, spoken, spoken_words = count_beat((sh or {}).get("beat"))
|
| 271 |
label = str((sh or {}).get("duration") or duration).strip()
|
| 272 |
if words < w0 or words > w1:
|
| 273 |
warnings.append(
|
|
|
|
| 283 |
f"{l0}" + (f"-{l1}" if l1 > l0 else "") + ". The line count is "
|
| 284 |
"a floor as well as a ceiling -- speaking seconds with nothing "
|
| 285 |
"assigned come back as invented dialogue.")
|
| 286 |
+
# Lines are the wrong unit on their own: one runs from six words to
|
| 287 |
+
# twenty. This is the arithmetic, and it is worth stating outright
|
| 288 |
+
# because the number the author needs is not the number of lines.
|
| 289 |
+
secs = DURATION_FRAMES.get(label, 0) / float(FPS or 24)
|
| 290 |
+
if spoken_words and secs:
|
| 291 |
+
speech = spoken_words / SPEECH_WPS
|
| 292 |
+
if speech < secs * SPEECH_MIN_SHARE:
|
| 293 |
+
warnings.append(
|
| 294 |
+
f"shot {i + 1}: {spoken_words} spoken words is about "
|
| 295 |
+
f"{speech:.1f}s of speech in a {secs:.0f}s hop. A character "
|
| 296 |
+
f"written as talking throughout needs roughly "
|
| 297 |
+
f"{int(secs * SPEECH_WPS)}. The seconds left over are a "
|
| 298 |
+
f"person visibly mid-sentence with nothing assigned, and "
|
| 299 |
+
f"the model fills them itself.")
|
| 300 |
|
| 301 |
# A person with pictures but no continuity text still *renders*, which is
|
| 302 |
# why `refs.check` only warns. The writer is the one place that can fill
|
|
|
|
| 583 |
+ (f"{l0}-{l1} spoken lines" if l1 > l0 else
|
| 584 |
f"{l0} spoken line" + ("" if l0 == 1 else "s"))
|
| 585 |
+ ". Count both in every beat before you answer.")
|
| 586 |
+
# Lines alone do not size the audio: one runs from six words to twenty,
|
| 587 |
+
# and six words is 2.4 s of a 10 s hop. Live, a 3x10 s vlog written
|
| 588 |
+
# with one six-word line per hop rendered 17.8% voiced, in fragments.
|
| 589 |
+
try:
|
| 590 |
+
from .h3_ref_chain import DURATION_FRAMES, FPS # noqa: PLC0415
|
| 591 |
+
secs = DURATION_FRAMES.get(str(duration).strip(), 0) / float(FPS or 24)
|
| 592 |
+
except Exception:
|
| 593 |
+
secs = 0
|
| 594 |
+
if secs:
|
| 595 |
+
lines.append(
|
| 596 |
+
f"Speech runs about {SPEECH_WPS:g} words a second, so a "
|
| 597 |
+
f"character who talks for most of a {secs:.0f}s hop needs "
|
| 598 |
+
f"roughly {int(secs * SPEECH_WPS)} words INSIDE the quotes -- "
|
| 599 |
+
f"several sentences, not one. Count the spoken words too. "
|
| 600 |
+
f"A hop where nobody speaks is fine, but say so: name the "
|
| 601 |
+
f"sound the room makes instead.")
|
| 602 |
pinned = [p for p in (pinned or []) if isinstance(p, dict)]
|
| 603 |
if pinned:
|
| 604 |
lines += ["",
|
|
@@ -396,6 +396,31 @@ def main():
|
|
| 396 |
ck("and overruled when the rail has one",
|
| 397 |
overruled["refs"][0].get("mp") == 0.54)
|
| 398 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
# A wardrobe plate on a continuation hop brings its own room. chain_00034
|
| 400 |
# was this on hop 2; the 3x10 s portrait chain on 2026-09-02 was this on
|
| 401 |
# hop 3 -- podcast_host.jpg is her sitting in a studio, it rode all three
|
|
|
|
| 396 |
ck("and overruled when the rail has one",
|
| 397 |
overruled["refs"][0].get("mp") == 0.54)
|
| 398 |
|
| 399 |
+
# Spoken WORDS, not lines. A line runs from six words to twenty, and six
|
| 400 |
+
# words is 2.4 s of a 10 s hop: chain_00059 was written one six-word line
|
| 401 |
+
# per 10 s hop and rendered 17.8% voiced, longest run 0.5 s.
|
| 402 |
+
ck("apostrophes are not quote delimiters",
|
| 403 |
+
PL.count_beat("She says, 'Today's class was long.' She waits.") == (8, 1, 4),
|
| 404 |
+
repr(PL.count_beat("She says, 'Today's class was long.' She waits.")))
|
| 405 |
+
ck("two spans are counted as two lines",
|
| 406 |
+
PL.count_beat("'I am happy!' and 'I never want to leave.'")[1:] == (2, 8),
|
| 407 |
+
repr(PL.count_beat("'I am happy!' and 'I never want to leave.'")))
|
| 408 |
+
|
| 409 |
+
def _talk(spoken):
|
| 410 |
+
return json.dumps({"shots": [
|
| 411 |
+
{"id": "s1", "beat": "@ref_1 walks and says, '" + spoken
|
| 412 |
+
+ "' She keeps going as the clip ends.",
|
| 413 |
+
"directives": {"tail": "hold"}}]})
|
| 414 |
+
|
| 415 |
+
thin = " ".join(["word"] * 6) # 2.4 s of a 10 s hop
|
| 416 |
+
full = " ".join(["word"] * 25) # about what 10 s holds
|
| 417 |
+
for label, spoken, want in (("a six-word line in a 10 s hop is flagged", thin, True),
|
| 418 |
+
("twenty-five words is not", full, False)):
|
| 419 |
+
_, ws = PL.validate(_talk(spoken), json.dumps(GOOD_REFS), hops=1,
|
| 420 |
+
known_files=kf, duration="10 s")
|
| 421 |
+
ck(label, any("spoken words is about" in w for w in ws) == want,
|
| 422 |
+
"; ".join(w for w in ws if "spoken words" in w)[:110])
|
| 423 |
+
|
| 424 |
# A wardrobe plate on a continuation hop brings its own room. chain_00034
|
| 425 |
# was this on hop 2; the 3x10 s portrait chain on 2026-09-02 was this on
|
| 426 |
# hop 3 -- podcast_host.jpg is her sitting in a studio, it rode all three
|