Sandpies Claude Opus 5 commited on
Commit
e7dfcea
·
1 Parent(s): 51e3ac4

writer: spoken WORDS size the audio, not the number of lines

Browse files

The 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

Files changed (3) hide show
  1. PROMPTING.md +21 -0
  2. planner.py +55 -4
  3. tools/check_planner.py +25 -0
PROMPTING.md CHANGED
@@ -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.
planner.py CHANGED
@@ -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
- return words, len(delims) // 2
 
 
 
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 += ["",
tools/check_planner.py CHANGED
@@ -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