Merge latest upstream Long Videos

This commit is contained in:
2026-09-17 15:57:25 +00:00
parent 9923a3e417
commit bfaa5fa733
8 changed files with 7146 additions and 2230 deletions
+289 -70
View File
@@ -66,21 +66,8 @@ HARDWARE = (
(r"cuffs?|cuffed", "cuffs", "wrists"),
(r"tape", "tape", "wrists"),
)
# Material and colour survive because they decide what the thing looks like:
# "steel collar" must not come back as "collar" two shots later.
# WHAT THE AUTHOR CALLED IT. This decides how much of the wording survives into
# the guard clauses, and the guard is what every shot after the first repeats --
# so a word missing here is a word the model stops hearing.
#
# It was twenty-odd words, and "a mirrored steel collar" came back as "steel
# collar" while "a brushed nickel collar" came back as "collar". A bare "collar"
# repeated once a shot is a bare collar, and the prior for that is a black
# leather one -- which is exactly what was reported.
#
# Hyphenated compounds pass whole ("mirror-finish", "chrome-plated"), so an
# unusual finish survives without being listed. Bare participles are NOT
# accepted: "-ed" is a verb far more often than a modifier, and capturing one
# would put an action into the name of the thing.
# Preserve visual modifiers in continuity text. Hyphenated compounds pass whole;
# arbitrary participles do not, because they are more often verbs than modifiers.
_ADJ = (r"(?:[A-Za-z]+-[A-Za-z]+|"
# materials
r"steel|stainless|iron|metal|metallic|nickel|chrome|chromed|brass|"
@@ -131,8 +118,26 @@ PART_VARIES = frozenset({"chain", "rope", "straps", "tape"})
# outlives the beat that caused it, and the clause that says so has to be
# writable from any later shot.
REGION_OF = (
# UNDERWEAR IS IN HERE TOO, on both halves of the body. The torso row has
# listed a bra since the day it was written -- that is the report it exists
# for, "a bra coming back on somebody topless" -- and the leg row never got
# its counterpart, so region_of("thong") answered "". A garment that cannot
# be placed latches no bare region, so underwear coming off said nothing
# about the hips in that shot or in any shot after it, and an unspecified
# region is filled by the model's own prior -- which for a hip is underwear.
#
# Worse, a beat saying somebody is NAKED looks each worn garment's region up
# to take it off the body, so the one garment it could not place stayed
# "worn" in the state while the text said she was nude. Reported as a thong
# restored a beat after she undressed to get in the shower.
#
# No hardware. A chastity belt is in the layering vocabulary, but it is a
# restraint: it is latched and held by its own mechanism, and a bare region
# read off it would argue with that.
(r"shorts|trousers|jeans|slacks|chinos|skirt|kilt|leggings|joggers|tights|"
r"pantyhose|jeggings|culottes|tracksuit\s+bottoms", "legs",
r"pantyhose|jeggings|culottes|tracksuit\s+bottoms|"
r"panties|knickers|thong|g-?string|briefs|boxers|underwear|undies|"
r"jockstrap|loincloth", "legs",
"The legs are bare from the hip down"),
(r"socks|stockings|hold-?ups|boots|shoes|trainers|sneakers|sandals|heels",
"feet", "The feet and ankles are bare"),
@@ -214,16 +219,34 @@ RELEASE_VERB = (
# the engine knew a cell and a warehouse, the sampler did not, so a scene set in
# either was a room to one reader and nowhere to the other. Same fault the
# garment lists had, waiting to be reported.
# MULTI-WORD ROOMS COME BEFORE BARE "room". _MOD is non-greedy, so it tries no
# modifier first and the longest place wins -- but only if the long form is here to
# win with. Without "locker\s+room", "heads to the locker room" read its destination
# as "room": _MOD swallowed "locker" and the capture took what was left, so the film
# was reported as entering "room" and the shot was told to arrive in one.
#
# A gym, a locker room and a court were in no list at all, which is worse than vague:
# with the ORIGIN unknown, travel_anchor emitted nothing, so a beat walking out of a
# gym was never told to walk and the set simply changed under the characters.
PLACES = (r"hallway|hall|corridor|passage|landing|stairwell|staircase|stairs|"
r"steps|bedroom|bathroom|washroom|kitchen|living\s+room|lounge|"
r"dining\s+room|study|office|garage|basement|cellar|attic|loft|porch|"
r"dining\s+room|locker\s+rooms?|changing\s+rooms?|dressing\s+rooms?|"
r"waiting\s+rooms?|utility\s+rooms?|gymnasium|gym|classroom|library|"
r"cafeteria|canteen|reception|laundry|pantry|sauna|balcony|terrace|"
r"rooftop|elevator|court|pool|showers|shower|store|shop|studio|"
r"study|office|garage|basement|cellar|attic|loft|porch|"
r"veranda|garden|yard|driveway|street|alley|car\s?park|lobby|foyer|"
r"doorway|cell|warehouse|barn|shed|van|truck|room")
# Place words that are also ordinary verbs or everyday nouns. A reader with a
# preposition in front of it ("in the study") can tell which sense is meant; the
# free-text one cannot, and "she steps out", "they study the map" and "he lands
# badly" are all commoner than the rooms they collide with.
PLACE_ALSO_A_VERB = {"steps", "landing", "study", "lounge", "garage", "porch"}
# "bar" and "lift" are deliberately NOT places in this file at all: bars are
# restraint hardware here ("chained to the bars") and lifting is what happens to a
# garment or a body, so behind a preposition they would both read as journeys.
PLACE_ALSO_A_VERB = {"steps", "landing", "study", "lounge", "garage", "porch",
"court", "pool", "shower", "showers", "store", "shop",
"studio", "reception"}
# A room is usually described, not just named -- "the tiled bathroom", "the long
# hallway". Up to three adjectives, non-greedy so the NEAREST room still wins.
_ROOM_MOD = (r"(?:(?!(?:of|the|an?|and|or|to|in|into|from|with|on|at|by|for|her|"
@@ -272,15 +295,21 @@ _GAP = r"(?:\s+\S+){0,4}?\s+"
TAKES_OFF = (r"(?:takes?|took|taking|pulls?|pulled|peels?|peeled|strips?|"
r"stripped|shrugs?|slips?|slipped|steps?|gets?|got|kicks?|"
r"kicked)" + _GAP + r"(?:off|out\s+of)\b"
r"|\b(?:removes?|removed|removing|discards?|discarded|"
r"undresses|undressed|unbuttons?|unzips?|unzipped)")
r"|\b(?:removes?|removed|removing|discards?|discarded|sheds?|shedding|"
r"undresses|undressed)")
PUTS_ON = (r"(?:puts?|putting|pulls?|pulled|slips?|slipped|tugs?|tugged|"
r"steps?|stepped|climbs?|climbed|gets?|got|wriggles?)" + _GAP +
r"(?:on|into|back\s+on)\b"
r"|\b(?:dresses?\s+in|dressed\s+in|buttons?|zips?\s+up|fastens?)")
# UNZIPPING A JACKET LEAVES IT ON. These were removals, so "Owen unzips his jacket"
# took the jacket out of every later shot and called his chest bare -- and "rolls up
# his sleeves" a beat later rolled the sleeves of a shirt that had gone with it. They
# open a garment; a beat that also takes it off says so ("and takes it off").
DISPLACES = (r"(?:pulls?|pulled|pushes?|pushed|tugs?|tugged|hikes?|hiked|"
r"rolls?|rolled|lifts?|lifted|yanks?|yanked|shoves?|shoved)"
+ _GAP + r"(?:aside|up|down|open)\b")
+ _GAP + r"(?:aside|up|down|open)\b"
r"|\b(?:unzips?|unzipped|unbuttons?|unbuttoned|unfastens?|unfastened|"
r"undoes|undid)\b")
# POSTURES and the _POSTURE list built from it used to live here. Nothing read
# _POSTURE -- it was a second, dead copy of the posture vocabulary, and it had
@@ -440,6 +469,17 @@ def _outside_speech(text):
return _SPOKEN_SPAN.sub(" ", text or "")
def staged_text(text):
"""What a beat STAGES: not what anybody says, and not what the narration asks.
A narrated question is the same kind of thing as a line of speech. "Maya waits by
the door. Will he come?" asks whether he will, which is to say he is not there --
and the "he" read as him being present, so Will was described into the shot of
her waiting for him. Removed for deciding who is in the shot, as speech is."""
staged = _outside_speech(text)
return " ".join(s for s in re.split(r"(?<=[.!?])\s+", staged) if not s.rstrip().endswith("?"))
# THREE modifiers, not two: "mirrored stainless steel collar" is three words and
# a noun, and the third was the first to be dropped.
_HW_ONE = _rx(r"\b(" + _ADJ + r"(?:\s+" + _ADJ + r"){0,2}\s+)?("
@@ -478,6 +518,14 @@ _GARMENT_ONE = _rx(r"\b(" + _ADJ + r"(?:\s+" + _ADJ + r"){0,2}\s+)?("
_TAKES_OFF = _rx(r"\b" + TAKES_OFF + r"\b")
_PUTS_ON = _rx(r"\b" + PUTS_ON + r"\b")
_DISPLACES = _rx(r"\b" + DISPLACES + r"\b")
# ...and the sentence can still finish the job after the garment is named. "Kate
# unzips the denim skirt and steps out of it": the unzip opens it, the rest of the
# sentence takes it off, and the removal verb comes after the item where the reader
# above does not look.
_OPENS_GARMENT = _rx(r"\b(?:unzips?|unzipped|unbuttons?|unbuttoned|unfastens?|unfastened|"
r"undoes|undid|unhooks?|unhooked|unclasps?|unclasped)\b")
_COMPLETES_OFF = _rx(r"\b(?:off|out\s+of|away|lets?\s+(?:it|them)\s+(?:fall|drop|slide)|"
r"drops?\s+(?:it|them)|falls?\s+(?:to|down|away|off))\b")
_MOVES = _rx(r"\b(?:walks?|walked|walking|goes|go|went|going|runs?|ran|running|"
r"steps?|stepped|stepping|moves?|moved|moving|enters?|entered|"
r"leaves?|left|leaving|crosses|crossed|crossing|climbs?|climbed|"
@@ -595,11 +643,6 @@ def nudity_in(text):
return out
def bare_sentence(region):
"""How to say a region is bare, or "" for one with no wording."""
return next((s for _rx, r, s in _REGION_RX if r == region), "")
def _bare_on(p, regions):
for r in ([regions] if isinstance(regions, str) else regions):
if r and r not in p.bare:
@@ -653,11 +696,6 @@ def _nearest_part(parts, at, ats):
return ""
def hardware_in(text):
"""Every piece of hardware named, as (canonical, part, as-written)."""
return [(c, p, w) for c, p, w, _at in hardware_spans(text)]
def position_spans(text):
"""Every limb position named, as (name, at)."""
out = []
@@ -705,9 +743,15 @@ def place_in(text):
Behind a preposition, so a room has to be somewhere somebody IS. "Ana looks
at the door" names no room -- and a door is not on the list in any case."""
m = _PLACE_IN.search(text or "")
text = text or ""
m = _PLACE_IN.search(text)
if not m:
return ""
clause = re.split(r"[.;!?]", text[:m.end()])[-1]
present = re.search(r"\b(?:is|are|was|were|stands?|sits?|waits?|lies?|remains?)\b",
clause, re.I)
if not _MOVES.search(clause) and not present:
return ""
got = re.sub(r"\s+", " ", m.group(1).lower()).strip()
# A BARE "room" NAMES NOWHERE. "Ana walks into the room" says she goes
# inside, not which room -- and taking it as a place produced "The shot is
@@ -730,6 +774,18 @@ def garments_in(text):
return out
_CLAUSE_BOUNDARY = re.compile(r"[,;]|\b(?:and|while)\b", re.I)
def _clause_at(text, at, boundaries=None):
"""Return the clause containing character offset `at` and its start offset."""
if boundaries is None:
boundaries = list(_CLAUSE_BOUNDARY.finditer(text or ""))
lo = max((m.end() for m in boundaries if m.end() <= at), default=0)
hi = min((m.start() for m in boundaries if m.start() > at), default=len(text))
return text[lo:hi], lo
# Hardware, not clothing. Taking clothes off does not unlock anything, so these
# are kept out of the garment answer -- the standing rule is that hardware is
@@ -740,6 +796,32 @@ _NOT_CLOTHING = re.compile(
r"straitjacket|spreader|hogtie|clamps?|clips?)$", re.I)
_PHRASE_ONE = _rx(r"\b(?:" + GARMENT_PHRASES + r")\b")
_WORD_ONE = _rx(r"^(?:" + GARMENT_WORDS + r")s?$")
# The same list with NO optional plural, which is what says whether a trailing "s"
# belongs to the word or was added to it. The vocabulary is clean on this: garments that
# are inherently plural are listed only in the plural (boots, jeans, shorts, panties,
# tights, leggings, socks, knickers, trousers, gloves) and the rest only in the singular
# (skirt, vest, top), so "stem is itself a garment" is an exact test and not a guess.
_WORD_EXACT = _rx(r"^(?:" + GARMENT_WORDS + r")$")
def singular_garment(word):
"""A garment word as the VOCABULARY spells it, so two readers cannot disagree.
Reported: several women take their skirts off and the skirts are back in the next
beat. "their skirts" yields the token "skirts" while the sheet says "a denim
skirt", and every reader downstream looks the token up in the sheet -- the scrub
by pattern, infer_removals by entry head -- so a plural garment matched nothing
and the removal silently did nothing at all. One woman undressing wrote "her
skirt" and worked; the moment the subject went plural so did the garment.
A trailing "s" comes off only when the stem is ITSELF a garment word, which is an
exact test here and not a guess: the vocabulary lists inherently plural garments
only in the plural (boots, jeans, shorts, panties, tights, leggings, socks,
knickers, trousers, gloves) and the rest only in the singular."""
low = str(word or "").lower().strip("-")
if low.endswith("s") and _WORD_EXACT.match(low[:-1]):
return low[:-1]
return low
def garment_words(text):
@@ -761,12 +843,60 @@ def garment_words(text):
text = _PHRASE_ONE.sub(" ", text)
for word in re.findall(r"\b[\w-]{3,}\b", text):
low = word.lower().strip("-")
if low in out or _NOT_CLOTHING.match(low):
if _NOT_CLOTHING.match(low):
continue
if _WORD_ONE.match(low):
if not _WORD_ONE.match(low):
continue
# The token the sheet wrote, not the one the beat happened to inflect. See
# singular_garment -- shared with infer_removals so the two cannot disagree.
low = singular_garment(low)
if low not in out:
out.append(low)
return out
# A POSTURE DENIED IS NOT A POSTURE TAKEN.
#
# Reported: a woman chained by the ankles and forced into a squat stood up anyway.
# The chain clauses were all correct -- the metal "already drawn to its full length,
# so the position it fixes is the position that keeps" was on every shot after the
# squat. What sat beside it was the posture latch saying she was STANDING, because
# "She cannot stand." matched `stand` and nothing looked at the `cannot`. The latch
# then carried that forward, so every later shot asserted, flatly and positively,
# the one thing the chains were there to prevent. At cfg 1 a positive statement wins.
#
# The same shape as _in_a_request, which already suppresses a posture that is ASKED
# for rather than taken. Attempts are here too: "tries to stand", "struggles to get
# up", "strains to rise" are all bodies that have NOT got there, and reading them as
# arrival is the same error in a friendlier disguise.
#
# A SHORT WINDOW on purpose -- the five words before the verb. The cue always sits
# immediately in front of it ("cannot stand", "no longer able to stand"), and a wider
# reach would let a `cannot` from a different clause silence a real posture.
_POSTURE_DENIED = _rx(
r"\b(?:cannot|can\s*not|can['\u2019]?t|could\s*not|could\s*n['\u2019]?t|"
r"unable|never|not\s+able|no\s+longer\s+able|"
r"does\s*n['\u2019]?t|does\s+not|do\s*n['\u2019]?t|did\s*n['\u2019]?t|did\s+not|"
r"will\s+not|wo\s*n['\u2019]?t|fail(?:s|ed|ing)?|"
r"tr(?:y|ies|ied|ying)|attempt(?:s|ed|ing)?|struggl(?:e|es|ed|ing)|"
r"strain(?:s|ed|ing)?|fight(?:s|ing)?|want(?:s|ed)?|need(?:s|ed)?|"
r"told|ordered|asked|begs?|begged)\b")
def denied_posture(text, at):
"""Is the posture verb at `at` negated, or only attempted, by what precedes it?"""
before = str(text or "")[:max(0, int(at))]
# A cue belongs to its OWN clause. Without stopping at the boundary, "McKenna
# cannot kneel, so she sits" reached back past the comma and silenced the sitting
# -- suppressing a posture the beat plainly states, which is the same class of
# error in the other direction.
cut = 0
for _m in re.finditer(r"[,;:.!?]|\b(?:so|and|but|then|yet|while|as|before|after)\b",
before, re.I):
cut = _m.end()
window = " ".join(re.findall(r"[\w'\u2019]+", before[cut:])[-5:])
return bool(_POSTURE_DENIED.search(window))
def posture_in(text):
"""The posture this beat puts a body in. '' when it does not.
@@ -775,7 +905,7 @@ def posture_in(text):
for that, but off the same vocabulary."""
t = text or ""
hits = sorted((m.start(), name) for name, rx in _POSTURE_OF
for m in [rx.search(t)] if m)
for m in rx.finditer(t) if not denied_posture(t, m.start()))
return hits[0][1] if hits else ""
@@ -820,7 +950,7 @@ _TRAILING_VERB = (r"take[sn]?|took|taking|pull(?:s|ed|ing)?|peel(?:s|ed|ing)?|"
r"toss(?:es|ed)?|throw[s]?|threw|kick(?:s|ed|ing)?|"
r"slide[s]?|slid|wriggle[sd]?|wiggle[sd]?")
# ...and verbs that are a removal on their own, needing no particle.
_UNDO_VERB = (r"remove[sd]?|removing|undress(?:es|ed)?|unzip(?:s|ped)?|"
_UNDO_VERB = (r"remove[sd]?|removing|undress(?:es|ed)?|shed(?:s|ding)?|unzip(?:s|ped)?|"
r"unbutton(?:s|ed)?|unhook(?:s|ed)?|unclasp(?:s|ed)?|unfasten(?:s|ed)?|"
# Hardware comes off by being UNDONE, and these were missing: a beat
# saying "unlocks the belt" left it described as worn for the rest of
@@ -830,11 +960,26 @@ _UNDO_VERB = (r"remove[sd]?|removing|undress(?:es|ed)?|unzip(?:s|ped)?|"
r"undo(?:es)?|undid")
# WHERE A GARMENT ENDS UP ONCE IT IS OFF. A thing on the floor is not on a body,
# and this is how a beat says so -- by DESTINATION, not by verb. Two readers need
# the same list, for opposite reasons: the removal reader to call it a removal,
# the restore reader to stop calling it one. "Lets the skirt fall" drops a lifted
# skirt back over her legs; "lets the thong fall to the floor" is the thong coming
# off, and the only difference between those two sentences is this list.
FLOOR = (r"floor|ground|tiles?|tiling|lino|mat|bath\s*mat|rug|carpet|deck|boards|"
r"concrete|grass|sand|bed|sofa|couch|chair|seat|stool|bench|basket|"
r"hamper|laundry|pile|heap")
TO_THE_FLOOR = (r"(?:to|on|onto|into|in)\s+(?:the|a|an|her|his|their)?\s*"
r"(?:" + FLOOR + r")\b")
_LANDS_OFF = _rx(r"\s*(?:fall(?:s|ing)?|drop(?:s|ping)?|land(?:s|ing)?)?\s*"
+ TO_THE_FLOOR)
_DISPLACE_WAY = (r"back\s+up|back\s+down|down|up|aside|open|back|"
r"off\s+(?:one|her|his|their)\s+shoulders?")
_DISPLACE = re.compile(
r"\b(?:" + _STRIP_VERB + r"|push(?:es|ed|ing)?|shove[sd]?|roll(?:s|ed|ing)?|"
r"hitch(?:es|ed)?|hike[sd]?|open(?:s|ed)?|undo(?:es)?|unzip(?:s|ped)?|"
r"hitch(?:es|ed)?|hike[sd]?|open(?:s|ed)?|undo(?:es)?|undid|unzip(?:s|ped)?|"
r"unbutton(?:s|ed)?|unfasten(?:s|ed)?|unhook(?:s|ed)?|unclasp(?:s|ed)?|"
# LIFTING A SKIRT IS DISPLACING IT, and none of these were here. Asked
# for directly: "when the skirt has been lifted up to show the chastity
# belt, that's when it should be shown". Lifting was not read as moving
@@ -874,14 +1019,23 @@ def scene_name_for(head, scene):
# a reference is pinning, so it is the worst one to describe loosely.
item = re.sub(r"<\s*picture\s+\d+\s*>", " ", item, flags=re.I)
item = re.sub(r"\s+", " ", item).strip()
if not item or item.split()[-1].lower() != head:
continue
# Drop a leading article or possessive; they are not description.
item = re.sub(r"^(?:a|an|the|her|his|their|its)\s+", "", item, flags=re.I)
# The longest entry wins: a sheet that names it twice described it most
# fully once, and the fuller name is the one worth carrying.
if len(item) > len(best):
best = item
# ONE ENTRY CAN HOLD SEVERAL GARMENTS, and the name is the garment's own
# part of it. "navy jacket over a white shirt" ends in "shirt", so the
# whole entry came back as the shirt's name and "the navy jacket over a
# white shirt open" was said about a shirt being unbuttoned. Split on the
# words that join garments, and cut a "with ..." tail, which describes a
# garment rather than naming it.
for part in re.split(r"\s+(?:over|under|beneath|underneath|on\s+top\s+of|and)\s+",
item, flags=re.I):
part = re.split(r"\s+with\s+", part, flags=re.I)[0].strip()
if not part or part.split()[-1].lower() != head:
continue
# Drop a leading article or possessive; they are not description.
part = re.sub(r"^(?:a|an|the|her|his|their|its)\s+", "", part, flags=re.I)
# The longest entry wins: a sheet that names it twice described it
# most fully once, and the fuller name is the one worth carrying.
if len(part) > len(best):
best = part
# The author's OWN capitalisation. Lowercasing turned "PVC" into "pvc" and
# "Shiny white crop top" into all-lowercase -- a different token sequence than
# was written, for a brand or material name that is capitalised for a reason.
@@ -910,6 +1064,13 @@ def displaced_garments(beat, scene):
if not way and re.match(r"\s*(?:lift|rais|hoist|gather|bunch)", m.group(0),
re.I):
way = "up"
# ...and UNDOING a garment opens it. "Owen unzips his jacket" was a removal,
# then (once it was not) nothing at all -- the jacket went back to being
# described closed on the next shot, which is a jacket zipping itself up
# across a cut.
if not way and re.match(r"\s*(?:unzip|unbutton|unfasten|unhook|unclasp|undo|undid)",
m.group(0), re.I):
way = "open"
if not way or not thing or thing in seen:
continue
# The garment has to be one the scene already dresses them in, and the head
@@ -987,9 +1148,26 @@ def restored_garments(beat, scene):
return []
out, low = [], scene.lower()
for m in _PUT_BACK_NAMED.finditer(beat):
# ...UNLESS IT LANDS ON THE FLOOR. These verbs are the restore vocabulary
# because that is what people write for a lifted skirt -- let fall, drop,
# lower, let go of -- and the identical words take a garment OFF when the
# sentence says where it lands. "Lets the thong fall to the floor" was read
# as putting the thong back on: the author's removal, enacted backwards, and
# from there the sheet described it as worn for the rest of the film.
# Reported as a thong restored after she undressed. See FLOOR.
if _LANDS_OFF.match(beat[m.end():]):
continue
thing = re.sub(r"\s+", " ", (m.group(1) or "")).strip().lower()
if not thing:
continue
# ...AND THE CAPTURE CANNOT RUN THROUGH A PREPOSITION. The group takes
# spaces so a sheet's "long grey skirt" comes back whole, and on "drops the
# thong on the floor" it swallowed "thong on the floor" instead -- head
# "floor" -- so the restore was keyed to the ROOM. The scene named a wet
# floor, scene_name_for handed back "tiled bathroom with a wet floor", and
# the beat was recorded as putting the bathroom back on.
if re.search(r"\b(?:on|onto|to|into|in|at|over|under|from|with|and)\b", thing):
continue
head = thing.split()[-1]
if len(head) < 3 or head not in low:
continue
@@ -1197,6 +1375,10 @@ def _alias_at(word, staged):
return fallback
_AUX_FOLLOWER = re.compile(r"\s+(?:I|you|he|she|we|they|it|there|this|that|anyone|someone|"
r"everyone|anybody|somebody)\b")
def names_in(beat, cast):
"""Names this beat STAGES, in the order the sentence puts them.
@@ -1208,14 +1390,23 @@ def names_in(beat, cast):
Speech-stripped, for the same reason it is everywhere else -- "McKenna, where
are you?" is how absence gets written, and reading it as presence put a whole
sheet entry into a shot the person is not in."""
staged = _outside_speech(beat or "")
staged = staged_text(beat or "")
names = [str(n) for n in (cast or []) if n]
hits, found = [], set()
for n in names:
m = re.search(r"\b" + re.escape(n) + r"\b", staged)
if m:
# A NAME THAT IS ALSO A WORD. "Will he come?" and "May I come in?" open a
# sentence with the name followed by who the question is about, and read as
# the person they staged Will and May -- a second character in a shot about
# somebody waiting for them. A name followed straight away by a subject
# pronoun at the start of a sentence is the verb; the next use can still be
# the person ("Will opens the gate").
for m in re.finditer(r"\b" + re.escape(n) + r"\b", staged):
_opens = re.search(r"(?:^|[.!?]\s*[\"'\u201c]?)\s*$", staged[:m.start()])
if _opens and _AUX_FOLLOWER.match(staged[m.end():]):
continue
hits.append((m.start(), n))
found.add(n)
break
# A SHEET NAME IS OFTEN LONGER THAN WHAT THE BEATS CALL HER. "Mistress Vale"
# on the sheet and "the Mistress" in every beat matched nothing, so her line
# was in no shot at all and the model invented her from scratch each time.
@@ -1415,20 +1606,35 @@ class SceneState:
subject = who[0] if who else next(iter(list(self.people) or list(cast)
or [""]))
hw = hardware_in(beat)
applying = bool(hw) and bool(_APPLY.search(beat)) and not _RELEASE.search(beat)
spans = hardware_spans(beat)
garments = list(_GARMENT_ONE.finditer(beat))
boundaries = list(_CLAUSE_BOUNDARY.finditer(beat)) if spans or garments else []
applying = bool(spans) and bool(_APPLY.search(beat))
releasing = bool(_RELEASE.search(beat))
if applying:
wearer = _wearer(beat, who, subject)
p = self.person(wearer)
if applying or releasing:
# MODIFIERS BIND TO THE NEAREST ITEM. "handcuffs her wrists behind
# her back and locks a steel collar around her neck, chained to the
# wall" carries two modifiers and two items; giving both modifiers
# to both items produced handcuffs chained to a wall they were never
# near, and a collar held behind a back.
spans = hardware_spans(beat)
for canon, part, written, at in spans:
clause, lo = _clause_at(beat, at, boundaries)
item_at = at - lo
apply_at = max((m.start() for m in _APPLY.finditer(clause)
if m.start() <= item_at), default=-1)
release_at = max((m.start() for m in _RELEASE.finditer(clause)
if m.start() <= item_at), default=-1)
if apply_at < 0 and release_at < 0:
continue
local_who = names_in(clause, cast)
wearer = _wearer(clause, local_who or who, subject)
p = self.person(wearer)
if release_at >= 0:
keys = [k for k in list(p.hardware) if k[0] == canon]
for key in keys:
changed["released"].append((wearer, p.hardware.pop(key)))
continue
# KEYED BY THE PAIR. A chain on the ankles and a chain on the
# wrists are two restraints; keyed by name alone the second
# overwrote the first and one of them was never drawn again.
@@ -1446,7 +1652,7 @@ class SceneState:
# Its anchor needs no transferring either: with the tether gone from
# the spans, the anchor binds to the nearest remaining item, which
# is the one it was always describing.
elif releasing:
if releasing and not spans:
# Whoever is actually wearing it. "The guard unlocks the handcuffs"
# names only the agent, and taking the subject there tried to
# release hardware from the man holding the key.
@@ -1457,13 +1663,8 @@ class SceneState:
# Released by NAME, whatever part it is on: an unlocking beat says
# "unlocks the chain", not which of two chains, and matching the
# pair left one fastened forever.
_kinds = {c for c, _pt, _w in hw}
named = [k for k in list(p.hardware) if k[0] in _kinds]
if named:
for key in named:
changed["released"].append((wearer, p.hardware.pop(key)))
elif re.search(r"\b(?:them|it|her|him|everything|all\s+of\s+it)\b",
beat, re.I):
if re.search(r"\b(?:them|it|her|him|everything|all\s+of\s+it)\b",
beat, re.I):
# "the guard releases her" names no item, so all of it comes off.
while p.hardware:
changed["released"].append((wearer, p.hardware.popitem()[1]))
@@ -1472,12 +1673,26 @@ class SceneState:
# to be named -- a bare "she undresses" says nothing about which garment,
# and guessing is how a garment came off a beat before the beat that
# took it off.
wearer_g = _wearer(beat, who, subject) if len(who) > 1 else subject
if wearer_g:
p = self.person(wearer_g)
for g in garments_in(beat):
if subject:
for m in garments:
g = f"{(m.group(1) or '').strip()} {m.group(2)}".strip().lower()
key = _garment_key(g)
if _TAKES_OFF.search(beat):
clause, lo = _clause_at(beat, m.start(), boundaries)
item_at = m.start() - lo
actions = [(x.start(), "off") for x in _TAKES_OFF.finditer(clause)
if x.start() <= item_at]
actions += [(x.start(), "on") for x in _PUTS_ON.finditer(clause)
if x.start() <= item_at]
actions += [(x.start(), "aside") for x in _DISPLACES.finditer(clause)
if x.start() <= item_at]
action = max(actions, default=(-1, ""))[1]
if action == "aside" and _OPENS_GARMENT.search(clause[:item_at]):
if _COMPLETES_OFF.search(re.split(r"[.;!?]", beat[m.end():])[0]):
action = "off"
local_who = names_in(clause, cast)
wearer_g = _wearer(clause, local_who, subject)
p = self.person(wearer_g)
if action == "off":
if key not in [_garment_key(x) for x in p.removed]:
p.removed.append(g)
changed["removed"].append((wearer_g, g))
@@ -1485,7 +1700,7 @@ class SceneState:
p.displaced = [x for x in p.displaced
if _garment_key(x) != key]
_bare_on(p, region_of(g))
elif _PUTS_ON.search(beat):
elif action == "on":
if key not in [_garment_key(x) for x in p.worn]:
p.worn.append(g)
changed["worn"].append((wearer_g, g))
@@ -1496,7 +1711,7 @@ class SceneState:
# dresses is told for the rest of the film that the region is
# bare, over the garment she just put on.
_bare_off(p, region_of(g))
elif _DISPLACES.search(beat):
elif action == "aside":
if key not in [_garment_key(x) for x in p.displaced]:
p.displaced.append(g)
changed["displaced"].append((wearer_g, g))
@@ -1506,7 +1721,11 @@ class SceneState:
# ever said what was on the chest.
_nude = nudity_in(beat)
if _nude:
for n in (who or ([subject] if subject else [])):
nude_at = min((m.start() for rx, _regions in _NUDITY_RX
for m in rx.finditer(beat)), default=len(beat))
located = [(abs(beat.find(n) - nude_at), n) for n in who if beat.find(n) >= 0]
owners = [min(located)[1]] if located else ([subject] if subject else [])
for n in owners:
q = self.person(n)
_bare_on(q, _nude)
# ...and it takes the garments OFF. Saying somebody is topless