cast prompt decrease

This commit is contained in:
Dag Thomas Olsen
2026-08-25 22:53:18 +02:00
parent 47eb2182d8
commit 1e5394ad3d
7 changed files with 456 additions and 257 deletions
+9 -22
View File
@@ -34,7 +34,7 @@ from .claude_code_support import (
resolve_project_name,
run_h3_claude_code,
)
from .scenes_store import recent_synopses, save_scene_bundle
from .scenes_store import save_scene_bundle
from .template_vars import collect_template_vars, expand_all, log_template_vars
from .characters import cast_line_name, split_cast_line
from .common import (
@@ -294,13 +294,14 @@ class H3ClaudeCodeCrossoverWriter:
# appended LAST so saved workflows keep their widget positions
optional.update(project_name_input())
optional["avoid_previous"] = ("INT", {
"default": 6, "min": 0, "max": 20,
"default": 0, "min": 0, "max": 20,
"tooltip": (
"Anti-repetition: feed the synopses of this many previous saved runs "
"(output/apnext_scenes/) back to the model as concepts that are USED UP, "
"so a new run invents something different instead of collapsing onto the "
"model's favourite ideas. The LLM has no memory between runs - similar "
"briefs otherwise produce near-identical stories. 0 = off."
"NO LONGER USED - every run is a clean slate. This once fed the synopses "
"of previous saved runs back to the model as concepts that were USED UP, "
"but quoting an old logline under a 'do not reuse' header primes the idea "
"far more reliably than it forbids it: the run reproduced what it was told "
"to avoid, got saved, and fed itself back. The slot stays so saved "
"workflows keep their widget positions; the value is ignored."
),
})
@@ -423,7 +424,6 @@ class H3ClaudeCodeCrossoverWriter:
locations="",
characters_only=True,
scene_briefs="",
avoid_synopses=(),
):
lines = ["CAST (use these strings verbatim in subject_definitions):"]
lines += [f"- {c}" for c in cast]
@@ -520,15 +520,6 @@ class H3ClaudeCodeCrossoverWriter:
lines.append(
"- The character with the most dialogue in a scene is <Subject 1> in that scene."
)
if avoid_synopses:
lines.append(
"- ALREADY MADE - THESE CONCEPTS ARE USED UP: earlier runs produced the "
"stories below. Do not reuse or lightly reskin their premises, settings, "
"motifs, plot beats or endings - invent something clearly different this "
"time:"
)
for i, syn in enumerate(avoid_synopses, 1):
lines.append(f" ({i}) {syn}")
wild_lines, wild_label = wildness_directive(wildness, rng)
lines += [f"- {w}" for w in wild_lines]
extra = (extra_instructions or "").strip()
@@ -577,7 +568,7 @@ class H3ClaudeCodeCrossoverWriter:
reference_image_use=None,
scene_briefs="",
project_name="",
avoid_previous=6,
avoid_previous=0,
**cast_slots,
):
project_name = resolve_project_name(project_name, seed)
@@ -613,9 +604,6 @@ class H3ClaudeCodeCrossoverWriter:
visual_style = resolve_visual_style(visual_style, custom_visual_style)
current_seed = seed if seed != -1 else random.randint(0, 0xffffffffffffffff)
rng = random.Random(current_seed)
avoid = tuple(recent_synopses("H3ClaudeCodeCrossoverWriter", int(avoid_previous or 0)))
if avoid:
print(f"🔁 H3 Crossover Writer: steering away from {len(avoid)} previous synopsis(es).")
user_prompt, wild_label = self._build_user_prompt(
cast,
@@ -636,7 +624,6 @@ class H3ClaudeCodeCrossoverWriter:
locations=locations,
characters_only=characters_only_refs(reference_image_use),
scene_briefs=scene_briefs,
avoid_synopses=avoid,
)
user_prompt = with_context(user_prompt, context_text)
+58 -45
View File
@@ -44,8 +44,8 @@ from .claude_code_crossover_writer import (
CAST_SOCKETS, cast_wardrobe, log_cast, log_wardrobe_locks, merge_wardrobe, parse_cast,
)
from .lyrics_transcribe import transcribe_song_lyrics
from .scenes_store import recent_synopses, save_scene_bundle
from .sound_events import parse_events, segment_event_lines, sync_key_lines
from .scenes_store import save_scene_bundle
from .sound_events import parse_events, segment_event_lines
from .template_vars import collect_template_vars, expand_all, log_template_vars
from .common import (
PROMPT_MODES,
@@ -91,6 +91,7 @@ from .scenes_support import (
LOCATIONS_TOOLTIP,
enforce_continuity,
enforce_continuity_chunked,
report_description_lengths,
locations_directive,
parse_location_lock,
parse_wardrobe_lock,
@@ -458,13 +459,14 @@ class H3ClaudeCodeMusicVideoWriter:
# appended LAST so saved workflows keep their widget positions
optional.update(project_name_input())
optional["avoid_previous"] = ("INT", {
"default": 6, "min": 0, "max": 20,
"default": 0, "min": 0, "max": 20,
"tooltip": (
"Anti-repetition: feed the synopses of this many previous saved runs "
"(output/apnext_scenes/) back to the model as concepts that are USED UP, "
"so a new run invents something different instead of collapsing onto the "
"model's favourite ideas. The LLM has no memory between runs - similar "
"briefs otherwise produce near-identical videos. 0 = off."
"NO LONGER USED - every run is a clean slate. This once fed the synopses "
"of previous saved runs back to the model as concepts that were USED UP, "
"but quoting an old logline under a 'do not reuse' header primes the idea "
"far more reliably than it forbids it: the run reproduced what it was told "
"to avoid, got saved, and fed itself back. The slot stays so saved "
"workflows keep their widget positions; the value is ignored."
),
})
optional["transcribe_lyrics"] = ("BOOLEAN", {
@@ -688,7 +690,6 @@ class H3ClaudeCodeMusicVideoWriter:
profile=None,
plan_only=False,
plan_text="",
avoid_synopses=(),
):
last = last or len(segments)
n = len(segments)
@@ -743,29 +744,37 @@ class H3ClaudeCodeMusicVideoWriter:
lines.append("DIRECTIVES:")
if beats:
lines.append(
"- SOUND: the `[+s]` lines under each piece are MEASURED moments in that "
"clip, timed from the clip's own start. Each one reads "
"`[+s] TYPE | size | what the music does` - the third column is the sound "
"itself, in plain words, and the size (light/solid/heavy) is how big it "
"actually is in the mix. Stage the picture ON those seconds."
"- SOUND: the bracket lines under each piece are MEASURED moments in "
"that clip, timed from the clip's own start - "
"`[+start ->+land ->+settle s] TYPE | size`. TYPE is what the music does "
"there; size (light/solid/heavy) is how big it is in the mix."
)
lines.append(
"- SYNC: every listed moment needs something VISIBLE happening on it, and "
"it is almost never a cut - one shot is usually the whole clip, so the "
"picture has to MOVE on the beat instead. Prefer physical effects the "
"camera can see land: a shockwave through dust or water, a gust of wind, a "
"blast, sparks, a light snapping on or off, fabric and hair thrown, the "
"ground pulsing, the camera shaken. What each kind wants:"
"- SYNC: give every listed moment something VISIBLE. WHAT is your call - "
"an object, a body, the light, the camera, an effect the scene already "
"has a reason to contain. Only scale it to the size, so a heavy moment "
"reads bigger than a light one and the big ones still mean something."
)
lines.extend(sync_key_lines(beats, indent=" "))
lines.append(
" Scale the response to the size: a heavy moment earns a whole-frame "
"event, a light one earns a glint or a flicker - staging every hit at full "
"force reads as noise and the drops stop meaning anything. Write the timing "
"and the effect into the shot description itself (\"at 2.1 s the bass hits "
"and dust jumps off the floor in a ring\") so it is unmistakable. Never "
"invent moments that are not listed, and never describe the sound as sound - "
"H3 is given the real song, so what you write is what the picture DOES."
"- THE THREE NUMBERS ARE THE TIMING, and they are the whole point. `land` "
"is the second the sound is on. START the move on the FIRST number so it "
"PEAKS exactly on the second, and let it settle through the third. "
"Anything described as beginning on the beat reads LATE - the eye "
"registers the peak of a move, not its first frame - and that is what "
"makes a finished video drift off its own song. Write it as three clauses "
"with the seconds stated (\"at 1.8 s ... lands on 2.1 s ... still "
"settling at 2.45 s\"); never state only the middle number."
)
lines.append(
"- `<opens already moving>` means the wind-up began before this clip: "
"open on a first frame ALREADY in motion, never from rest. `<lands in the "
"next clip>` means the peak falls past this clip's end: climb to the last "
"frame and do NOT resolve it here."
)
lines.append(
"- Never invent moments that are not listed, never move one to a rounder "
"second, and never describe the sound as sound - H3 is given the real "
"song, so what you write is what the picture DOES."
)
if plan_only:
lines.append(
@@ -822,7 +831,11 @@ class H3ClaudeCodeMusicVideoWriter:
"the lens (or in a clear profile) with the mouth fully visible - no hands, "
"microphones, hair, shadows or props covering it - and readable mouth and "
f"jaw movement locked to {audio_ref} from the first frame of the line to the "
"last. Frame sung lines MEDIUM CLOSE-UP or closer (the face large in frame) "
"last. State that lock ONCE per scene, at the first sung line; the later "
"lines just carry their `<d>...</d>` and what the performer is doing - "
"repeating the same lip-sync sentence for every line is a third of the "
"scene's word budget spent saying one thing four times. "
"Frame sung lines MEDIUM CLOSE-UP or closer (the face large in frame) "
"and keep the camera slow and smooth while a line lasts; save wide shots and "
"fast moves for the instrumental moments. Never cut away from the singer in "
"the middle of a sung line."
@@ -963,16 +976,17 @@ class H3ClaudeCodeMusicVideoWriter:
"to its era, palette and textures in every scene instead of defaulting to "
"a generic contemporary city."
)
# only the planning turn needs the avoid-list; chunk turns follow the plan
if avoid_synopses and (plan_only or (first == 1 and not plan_text)):
lines.append(
"- ALREADY MADE - THESE CONCEPTS ARE USED UP: earlier runs produced the "
"videos below. Do not reuse or lightly reskin their premises, settings, "
"motifs, plot beats or endings - invent something clearly different this "
"time:"
)
for i, syn in enumerate(avoid_synopses, 1):
lines.append(f" ({i}) {syn}")
lines.append(
"- LENGTH - H3's own budget, and it is a ceiling, not a target to fill: "
"`integrated_multimodal_description` is 350-500 words for the WHOLE scene, "
"every shot together; `overall_soundscape` is 1-4 sentences; "
"`non_diegetic_music` is 1-3 sentences; `subject_definitions` is one line per "
"subject. Spend those words on what the camera sees that is NEW in this "
"scene. Never spend them repeating an outfit the `<Subject N>` label already "
"carries, re-listing a room an earlier shot already fixed, or restating "
"anything the scene has established - that is the padding that pushes a "
"description past its budget and buys nothing."
)
lines.append(f"- {_ANTI_CLICHE_DIRECTIVE}")
lines.append(f"- {LITERAL_CAMERA_DIRECTIVE}")
if profile:
@@ -1059,7 +1073,7 @@ class H3ClaudeCodeMusicVideoWriter:
draft_model="haiku",
parallel_chunks=True,
project_name="",
avoid_previous=6,
avoid_previous=0,
transcribe_lyrics=True,
vocals=None,
sound_events="",
@@ -1177,9 +1191,6 @@ class H3ClaudeCodeMusicVideoWriter:
ending_picks = tuple(rng.sample(ENDING_MOVES, 2))
# sampled AFTER the plot/ending picks, so existing seeds keep them
world_picks = tuple(rng.sample(WORLD_FLAVORS, 2))
avoid = tuple(recent_synopses("H3ClaudeCodeMusicVideoWriter", int(avoid_previous or 0)))
if avoid:
print(f"🔁 H3 Music Video Writer: steering away from {len(avoid)} previous synopsis(es).")
local = local_llm_options(llm)
chars_only = characters_only_refs(reference_image_use)
masked_audio = str(audio_mode or "").startswith("Masked")
@@ -1227,7 +1238,6 @@ class H3ClaudeCodeMusicVideoWriter:
prior_scenes=prior, plot_picks=plot_picks, ending_picks=ending_picks,
world_picks=world_picks,
profile=profile, plan_only=plan_only, plan_text=plan_text,
avoid_synopses=avoid,
)
return user_prompt
@@ -1394,14 +1404,17 @@ class H3ClaudeCodeMusicVideoWriter:
if len(parsed) == n and n <= SCENES_PER_CALL:
synopsis, parsed, info = enforce_continuity(
enforce_wardrobe, synopsis, parsed, n, durations[0], session_id, info, repair,
cast=cast,
)
else:
# multi-chunk runs: a full re-emit is too long, so one repair
# turn re-emits only the violating scenes and splices them in
parsed, info = enforce_continuity_chunked(
enforce_wardrobe, synopsis, parsed, session_id, info, repair,
enforce_wardrobe, synopsis, parsed, session_id, info, repair, cast=cast,
)
info = f"{info} | {report_description_lengths([p for _, _, p in parsed])}"
# pad / trim to exactly one scene per piece so the lists stay aligned
scenes = [p for _, _, p in parsed][:n]
while len(scenes) < n:
+9 -22
View File
@@ -44,7 +44,7 @@ from .claude_code_crossover_writer import (
)
from .claude_code_music_video_writer import _scene_gist
from .music_support import fmt_time, frames_for_seconds
from .scenes_store import recent_synopses, save_scene_bundle
from .scenes_store import save_scene_bundle
from .template_vars import collect_template_vars, expand_all, log_template_vars
from .common import (
PROMPT_MODES,
@@ -395,12 +395,14 @@ class H3ClaudeCodePresentationWriter:
# appended LAST so saved workflows keep their widget positions
optional.update(project_name_input())
optional["avoid_previous"] = ("INT", {
"default": 6, "min": 0, "max": 20,
"default": 0, "min": 0, "max": 20,
"tooltip": (
"Anti-repetition: feed the synopses of this many previous saved runs "
"(output/apnext_scenes/) back to the model as stagings that are USED UP, "
"so a new run picks a different presenter, venue and visual-aid staging "
"(the facts stay verbatim regardless). 0 = off."
"NO LONGER USED - every run is a clean slate. This once fed the synopses "
"of previous saved runs back to the model as concepts that were USED UP, "
"but quoting an old logline under a 'do not reuse' header primes the idea "
"far more reliably than it forbids it: the run reproduced what it was told "
"to avoid, got saved, and fed itself back. The slot stays so saved "
"workflows keep their widget positions; the value is ignored."
),
})
@@ -576,7 +578,6 @@ class H3ClaudeCodePresentationWriter:
prior_scenes=(),
plan_only=False,
plan_text="",
avoid_synopses=(),
):
n = scene_count
last = last or n
@@ -761,16 +762,6 @@ class H3ClaudeCodePresentationWriter:
"same board, same inset style, same title styling) so the talk reads as one "
"production."
)
# only the planning turn needs the avoid-list; chunk turns follow the plan
if avoid_synopses and (plan_only or (first == 1 and not plan_text)):
lines.append(
"- ALREADY MADE - THESE STAGINGS ARE USED UP: earlier runs produced the "
"talks below. The facts stay verbatim, but do not reuse their presenter, "
"venue, format or visual-aid staging - make clearly different staging "
"choices this time:"
)
for i, syn in enumerate(avoid_synopses, 1):
lines.append(f" ({i}) {syn}")
if briefs:
lines.append(
"- SCENE BRIEFS ARE BINDING: a numbered brief is the plan for that scene - "
@@ -846,7 +837,7 @@ class H3ClaudeCodePresentationWriter:
draft_model="haiku",
parallel_chunks=True,
project_name="",
avoid_previous=6,
avoid_previous=0,
**cast_slots,
):
project_name = resolve_project_name(project_name, seed)
@@ -897,9 +888,6 @@ class H3ClaudeCodePresentationWriter:
dialogue_language = resolve_dialogue_language(dialogue_language, custom_dialogue_language)
visual_style = resolve_visual_style(visual_style, custom_visual_style)
avoid = tuple(recent_synopses("H3ClaudeCodePresentationWriter", int(avoid_previous or 0)))
if avoid:
print(f"🔁 H3 Presentation Writer: steering away from {len(avoid)} previous synopsis(es).")
local = local_llm_options(llm)
chars_only = characters_only_refs(reference_image_use)
skills = PRESENTATION_SKILLS if ref_mode else BASE_SKILLS
@@ -945,7 +933,6 @@ class H3ClaudeCodePresentationWriter:
image_notes=image_notes, first=lo, last=hi,
characters_only=chars_only, scene_briefs=scene_briefs,
prior_scenes=prior, plan_only=plan_only, plan_text=plan_text,
avoid_synopses=avoid,
)
def merge_locks(text):
+10 -22
View File
@@ -45,7 +45,7 @@ from .claude_code_crossover_writer import (
from .claude_code_music_video_writer import _scene_gist
from .claude_code_presentation_writer import _extract_script, _scene_table
from .music_support import fmt_time, frames_for_seconds
from .scenes_store import recent_synopses, save_scene_bundle
from .scenes_store import save_scene_bundle
from .template_vars import collect_template_vars, expand_all, log_template_vars
from .common import (
PROMPT_MODES,
@@ -238,12 +238,14 @@ class H3ClaudeCodeShortFilmWriter:
# appended LAST so saved workflows keep their widget positions
optional.update(project_name_input())
optional["avoid_previous"] = ("INT", {
"default": 6, "min": 0, "max": 20,
"default": 0, "min": 0, "max": 20,
"tooltip": (
"Anti-repetition: feed the synopses of this many previous saved runs "
"(output/apnext_scenes/) back to the model as concepts that are USED UP, "
"so where the manuscript leaves choices open a new run makes different "
"ones. 0 = off."
"NO LONGER USED - every run is a clean slate. This once fed the synopses "
"of previous saved runs back to the model as concepts that were USED UP, "
"but quoting an old logline under a 'do not reuse' header primes the idea "
"far more reliably than it forbids it: the run reproduced what it was told "
"to avoid, got saved, and fed itself back. The slot stays so saved "
"workflows keep their widget positions; the value is ignored."
),
})
@@ -371,7 +373,7 @@ class H3ClaudeCodeShortFilmWriter:
include_non_diegetic_music, extra_instructions, rng,
wardrobe="", locations="", image_labels=(), image_notes="", blind_refs=False,
first=1, last=None, characters_only=True, scene_briefs="", prior_scenes=(),
plan_only=False, plan_text="", avoid_synopses=(),
plan_only=False, plan_text="",
):
last = last or n
briefs = (scene_briefs or "").strip()
@@ -528,16 +530,6 @@ class H3ClaudeCodeShortFilmWriter:
"give one character's wardrobe or hair to another, and keep each `<d>` line "
"with its own speaker - only that character's mouth moves on their line."
)
# only the planning turn needs the avoid-list; chunk turns follow the plan
if avoid_synopses and (plan_only or (first == 1 and not plan_text)):
lines.append(
"- ALREADY MADE - THESE CONCEPTS ARE USED UP: earlier runs produced the "
"films below. Where the manuscript leaves choices open, do not reuse or "
"lightly reskin their settings, imagery, motifs or staging - make "
"clearly different choices this time:"
)
for i, syn in enumerate(avoid_synopses, 1):
lines.append(f" ({i}) {syn}")
lines.append(f"- {LITERAL_CAMERA_DIRECTIVE}")
if briefs:
lines.append(
@@ -573,7 +565,7 @@ class H3ClaudeCodeShortFilmWriter:
llm=None, reference_image_use=None, scene_briefs="",
save_scenes=True, scenes_per_call=SCENES_PER_CALL, prompt_mode=None,
draft_model="haiku", parallel_chunks=True, project_name="",
avoid_previous=6,
avoid_previous=0,
**cast_slots,
):
import random
@@ -625,9 +617,6 @@ class H3ClaudeCodeShortFilmWriter:
visual_style = resolve_visual_style(visual_style, custom_visual_style)
current_seed = seed if seed != -1 else random.randint(0, 0xffffffffffffffff)
rng = random.Random(current_seed)
avoid = tuple(recent_synopses("H3ClaudeCodeShortFilmWriter", int(avoid_previous or 0)))
if avoid:
print(f"🔁 H3 Short Film Writer: steering away from {len(avoid)} previous synopsis(es).")
local = local_llm_options(llm)
chars_only = characters_only_refs(reference_image_use)
skills = FILM_SKILLS if ref_mode else BASE_SKILLS
@@ -671,7 +660,6 @@ class H3ClaudeCodeShortFilmWriter:
image_notes=image_notes, first=lo, last=hi, characters_only=chars_only,
scene_briefs=scene_briefs, prior_scenes=prior,
plan_only=plan_only, plan_text=plan_text,
avoid_synopses=avoid,
)
def merge_locks(text):
-30
View File
@@ -77,36 +77,6 @@ def save_scene_bundle(source, synopsis, scenes, segments, durations, lengths,
return None
def recent_synopses(source, limit):
"""
The synopses of the newest saved bundles from `source` (a writer class
name), newest first - the writers feed these back as an avoid-list so a
new run does not reinvent the previous run's concept. The LLM has no
memory between runs; without this, identical briefs collapse onto the
model's favourite ideas and every video comes out the same.
"""
out = []
if limit <= 0:
return out
try:
d = scenes_dir()
for fname in _list_saved():
try:
with open(os.path.join(d, fname), encoding="utf-8") as f:
data = json.load(f)
except Exception:
continue
if data.get("kind") != _KIND or data.get("source") != source:
continue
syn = " ".join((data.get("synopsis") or "").split())
if syn:
out.append(syn[:450])
if len(out) >= limit:
break
except Exception:
pass
return out
def _list_saved():
try:
+218 -38
View File
@@ -25,6 +25,16 @@ _SCENE_RE = re.compile(
)
# The four H3 sections, in order. Used as the stop-list when pulling one
# section back out of a finished scene.
_SCENE_FIELDS = (
"subject_definitions",
"integrated_multimodal_description",
"overall_soundscape",
"non_diegetic_music",
)
def envelope_contract(section_labels):
"""The output-contract paragraph both writers append to their system prompt."""
labels = ", ".join(f"`{label}:`" for label in section_labels)
@@ -52,17 +62,20 @@ def envelope_contract(section_labels):
WARDROBE_TOOLTIP = (
"Wardrobe lock, one line per character, e.g. `Sheldon: brown corduroy jacket, green "
"Wardrobe lock, one line per CAST member, e.g. `Sheldon: brown corduroy jacket, green "
"Flash T-shirt, khaki trousers, small silver ring in the left nostril`. Used word-for-word "
"in every shot. Empty = Claude fixes one outfit per character itself (in the synopsis) "
"and repeats it in every shot."
"in subject_definitions and at the character's first appearance in each scene; later "
"shots carry it on the `<Subject N>` label, as H3's guide specifies. Empty = Claude "
"fixes one outfit per cast member itself (in the synopsis) and reuses it. Only the "
"cast is locked - extras and background people are described where they appear and "
"never carry an anchor set."
)
ENFORCE_WARDROBE_TOOLTIP = (
"After writing, check that every shot a character is in restates all of that "
"character's wardrobe anchors verbatim, and that every scene set in a locked location "
"restates that location's anchors. If anything is dropped or changed, the model gets "
"one repair turn in the same session. Off = trust the first answer."
"After writing, check that each character's FIRST appearance in a scene states all of "
"that character's wardrobe anchors verbatim, and that every scene set in a locked "
"location restates that location's anchors. If anything is dropped or changed, the "
"model gets one repair turn in the same session. Off = trust the first answer."
)
LOCATIONS_TOOLTIP = (
@@ -103,8 +116,10 @@ def locations_directive(locations=""):
"Location lock (from the user) - copy these anchors word-for-word as the "
"synopsis `Locations:` lines and, in EVERY scene set in that place, into the "
"first shot of the scene right after the place is named (`... in Sheldon's "
"living room - <anchors> - ...`); later shots in the same scene restate the "
"anchors that are in frame. " + _LOCATION_ANCHOR_RULES +
"living room - <anchors> - ...`). Later shots in the same place do NOT "
"re-inventory the room: name only the one or two anchors actually in that "
"frame, the way the guide asks for concrete frame anchors within what is "
"visible. " + _LOCATION_ANCHOR_RULES +
"\n" + "\n".join(f" {line}" for line in lines)
)
return (
@@ -122,6 +137,29 @@ def locations_directive(locations=""):
"scene needs no lock. " + _LOCATION_ANCHOR_RULES
)
# The official guide, 5.3: "At the first clear appearance of an important
# <Subject N>, describe its referenced characteristics, position in the frame,
# and current action within what is actually visible in the shot. Continue
# using the same label in later shots without redefining what the label
# represents." Restating the outfit every shot is not extra safety - it is
# 150-250 words a scene the description does not get to spend on the picture.
_CAST_ONLY = (
"Lock ONLY the cast. Everyone else the story needs - crowds, extras, a "
"shopkeeper, a neighbour, a passer-by, a friend in the background - gets NO "
"`Wardrobe:` line and no anchor set. Describe them once, briefly, where they "
"appear, in whatever detail that frame actually shows, and let them go. A "
"locked outfit is a promise to restate it in full every time that person is on "
"screen; spending that on someone standing at the back of one shot is a large "
"part of the scene's word budget bought for nothing."
)
_LABEL_CARRIES = (
"In the REST of that scene use the bare label (`<Subject 1>` or the character's "
"name) and never repeat the outfit: the label already carries it. Describe only "
"what changes - what they are doing, where they are in frame, what the light is "
"doing to them - plus any anchor the action puts in close-up."
)
_ANCHOR_RULES = (
"Anchor rules: every anchor is one exact phrase - a precise colour word plus material "
"or pattern plus garment (`dark-brown corduroy jacket`, `forest-green cotton T-shirt`), "
@@ -139,22 +177,33 @@ _ANCHOR_RULES = (
def wardrobe_directive(wardrobe=""):
"""
Wardrobe drifts between scenes unless each outfit is fixed once and copied
into EVERY shot. With user text: that text is the lock. Without: Claude
writes the lock into the synopsis first, then reuses it.
Wardrobe drifts between scenes unless each outfit is fixed once and restated
where H3 actually binds it: the character's FIRST appearance in the scene.
It used to be restated in every shot, which is what the official guide tells
you not to do - "continue using the same label in later shots without
redefining what the label represents". H3 binds the description to the
`<Subject N>` label at its first clear appearance and carries it through the
clip, so the repeats bought no fidelity and cost 150-250 words a scene out
of a description the guide budgets at 350-500.
With user text: that text is the lock. Without: Claude writes the lock into
the synopsis first, then reuses it.
"""
wardrobe = (wardrobe or "").strip()
if wardrobe:
lines = [line.strip() for line in wardrobe.splitlines() if line.strip()]
return (
"Wardrobe lock (from the user) - copy these anchors word-for-word as the "
"synopsis `Wardrobe:` lines and into EVERY shot in which that character is on "
"screen, right after the first mention of the character in that shot "
"(`... <Subject 1> Sheldon (S1), wearing <anchors>, ...`). " + _ANCHOR_RULES +
"synopsis `Wardrobe:` lines, into that character's `<Subject N>` line in "
"subject_definitions, and into the FIRST shot of each scene in which the "
"character is on screen, right after the first mention of them in that shot "
"(`... <Subject 1> Sheldon (S1), wearing <anchors>, ...`). " + _LABEL_CARRIES
+ " " + _CAST_ONLY + " " + _ANCHOR_RULES +
"\n" + "\n".join(f" {line}" for line in lines)
)
return (
"Wardrobe lock: before scene 01, DESIGN one outfit per character the way a "
"Wardrobe lock: before scene 01, DESIGN one outfit per CAST member the way a "
"costume department would - it fits the concept's genre, era, season and "
"setting and the character's role, age and status in the story; its colours "
"read on camera against the locked locations (contrast, not camouflage); and "
@@ -165,10 +214,11 @@ def wardrobe_directive(wardrobe=""):
" Wardrobe:\n"
" Sheldon Cooper: dark-brown corduroy jacket, forest-green cotton T-shirt, "
"khaki chino trousers, short side-parted brown hair\n"
"Then copy that character's anchors word-for-word into EVERY shot in which the "
"character is on screen, right after the first mention of the character in that "
"shot (`... <Subject 1> Sheldon Cooper (S1), wearing <anchors>, ...`). "
+ _ANCHOR_RULES
"Then copy that character's anchors word-for-word into their `<Subject N>` line "
"in subject_definitions and into the FIRST shot of each scene in which the "
"character is on screen, right after the first mention of them in that shot "
"(`... <Subject 1> Sheldon Cooper (S1), wearing <anchors>, ...`). " + _LABEL_CARRIES
+ " " + _CAST_ONLY + " " + _ANCHOR_RULES
)
@@ -232,6 +282,52 @@ def _parse_lock_block(synopsis, headers):
return locks
def cast_names(cast):
"""
The bare names in a cast block - the `Name` of each `Name: description` line.
Lines with no colon are free-form descriptions rather than named parts and
have no name to match on, so they are skipped.
"""
names = []
for line in (cast if isinstance(cast, (list, tuple)) else (cast or "").splitlines()):
head, sep, _ = str(line).strip().lstrip("-*• ").partition(":")
if not sep:
continue
head = re.sub(r"\s*\(.*?\)\s*", " ", head).strip()
if head:
names.append(head)
return names
def restrict_to_cast(locks, names):
"""
Drop wardrobe locks for anyone who is not in the cast.
A lock is a standing promise to restate a full anchor set every time that
person appears. That is worth it for the people the video is about, and it
is most of a scene's word budget when the model has quietly locked the
neighbour, the shopkeeper and two kids in the background as well.
With no named cast (the model invents the performer) there is nothing to
filter against, so every lock stands - failing open, because dropping every
lock would silently disable the continuity check.
"""
if not names or not locks:
return locks, []
wanted = {n.strip().lower() for n in names}
firsts = {n.split()[0] for n in wanted if n.split()}
kept, dropped = {}, []
for name, anchors in locks.items():
low = name.strip().lower()
first = low.split()[0] if low.split() else low
if low in wanted or first in firsts or any(w.split()[0] == first for w in wanted if w.split()):
kept[name] = anchors
else:
dropped.append(name)
return (kept or locks), ([] if not kept else dropped)
def _name_patterns(name):
"""Regexes that mean `this character is mentioned`: full name, then first name."""
full = re.escape(name.strip())
@@ -244,8 +340,16 @@ def _name_patterns(name):
def wardrobe_violations(scenes, locks):
"""
[(scene_no, shot_no, name, anchor), ...] for every shot in which a locked
character is on screen but an anchor is missing verbatim (case-insensitive).
[(scene_no, shot_no, name, anchor), ...] for the shot in which a locked
character FIRST appears in a scene without an anchor stated verbatim
(case-insensitive).
Once per scene, not once per shot. That is where H3 binds the outfit to the
`<Subject N>` label; later shots reuse the label, and demanding the full
parenthetical again in each of them is what pushed these descriptions past
the guide's 350-500 word budget. Checking the first appearance still catches
the failure that matters - a character arriving with no outfit fixed at all.
Sentences that say the character is NOT in frame do not count as presence.
"""
found = []
@@ -255,16 +359,17 @@ def wardrobe_violations(scenes, locks):
for scene_no, prompt in enumerate(scenes, 1):
parts = _SHOT_SPLIT_RE.split(prompt)
# parts: [pre, "[Shot 1]", body1, "[Shot 2]", body2, ...]
pending = dict(locks)
shot_no = 0
for k in range(1, len(parts), 2):
shot_no += 1
if not pending:
break
body = parts[k + 1] if k + 1 < len(parts) else ""
visible = _OFFSCREEN_RE.sub(" ", body)
low = body.lower()
for name, anchors in locks.items():
if not any(p.search(visible) for p in patterns[name]):
continue
for anchor in anchors:
for name in [n for n in pending if any(p.search(visible) for p in patterns[n])]:
for anchor in pending.pop(name):
if anchor.lower() not in low:
found.append((scene_no, shot_no, name, anchor))
return found
@@ -284,12 +389,13 @@ def wardrobe_repair_prompt(locks, violations, scene_count):
return (
"WARDROBE CHECK FAILED. The locked wardrobe from your synopsis is:\n"
f"{lock_lines}\n"
"These shots have the character on screen without restating every anchor "
"verbatim:\n"
"These are the shots where the character FIRST appears in their scene "
"without every anchor stated verbatim:\n"
f"{issue_lines}\n"
"Fix them: in every shot in which a character is on screen, restate ALL of that "
"Fix them: at each character's FIRST appearance in a scene, state ALL of that "
"character's anchors character-for-character (same words, same colours, same "
"side), right after the first mention of the character in that shot. Do not "
"side), right after the first mention of them in that shot. Leave the later "
"shots alone - they use the bare label and must NOT repeat the outfit. Do not "
"change the story, dialogue, timecodes, durations, shot count or anything else. "
f"Return the COMPLETE output again in the exact same contract: the synopsis block "
f"and all {scene_count} scene envelopes."
@@ -340,6 +446,75 @@ def location_violations(scenes, locks):
return found
# ----------------------------------------------------------------------
# Description length
# ----------------------------------------------------------------------
# H3's own budget for a generation task, from the reference guide: "For
# generation tasks, `detailed_description` is normally 350-500 English words."
# It is a budget, not a target - but with nothing measuring it the writers had
# no way to know they were routinely at 600+, and neither did anyone reading
# the output.
DESCRIPTION_MIN_WORDS = 350
DESCRIPTION_MAX_WORDS = 500
def description_lengths(scenes, field="integrated_multimodal_description",
all_fields=_SCENE_FIELDS):
"""
[(scene_no, words), ...] - the word count of each scene's description.
Counts only the description. The other three sections are a line or two by
construction, so a scene that blew its budget blew it here.
"""
from .common import extract_section
out = []
for scene_no, prompt in enumerate(scenes, 1):
body = extract_section(prompt or "", field, all_fields)
out.append((scene_no, len(body.split())))
return out
def description_summary(lengths):
"""One line: how the scenes sit against H3's 350-500 word budget."""
counts = [w for _, w in lengths if w]
if not counts:
return "description: nothing to measure"
counts.sort()
median = counts[len(counts) // 2]
over = [n for n, w in lengths if w > DESCRIPTION_MAX_WORDS]
under = [n for n, w in lengths if 0 < w < DESCRIPTION_MIN_WORDS]
parts = [f"description: median {median} words (budget "
f"{DESCRIPTION_MIN_WORDS}-{DESCRIPTION_MAX_WORDS})"]
if over:
parts.append(f"{len(over)} over (worst {max(counts)})")
if under:
parts.append(f"{len(under)} thin")
if not over and not under:
parts.append("all in budget")
return ", ".join(parts)
def report_description_lengths(scenes, field="integrated_multimodal_description",
all_fields=_SCENE_FIELDS):
"""
Print the length summary and name the scenes that overran. Returns the
summary so callers can fold it into their info string.
Reported, never repaired: asking for a rewrite to hit a word count trades a
known-good scene for a shorter one, and the budget is guidance about where
detail stops paying - not a contract the node should enforce behind the
user's back.
"""
lengths = description_lengths(scenes, field, all_fields)
summary = description_summary(lengths)
print(f"📏 {summary}")
over = sorted(((w, n) for n, w in lengths if w > DESCRIPTION_MAX_WORDS), reverse=True)
for w, n in over[:5]:
print(f" ↳ scene {n:02d}: {w} words, {w - DESCRIPTION_MAX_WORDS} over")
return summary
def location_summary(locks, violations):
if not locks:
return "locations: no lock found in synopsis"
@@ -364,12 +539,13 @@ def _repair_issue_parts(wardrobe_locks, wardrobe_misses, location_locks, locatio
parts.append(
"WARDROBE CHECK FAILED. The locked wardrobe from your synopsis is:\n"
f"{lock_lines}\n"
"These shots have the character on screen without restating every anchor "
"verbatim:\n"
"These are the shots where the character FIRST appears in their scene "
"without every anchor stated verbatim:\n"
f"{issue_lines}\n"
"Fix them: in every shot in which a character is on screen, restate ALL of that "
"Fix them: at each character's FIRST appearance in a scene, state ALL of that "
"character's anchors character-for-character (same words, same colours, same "
"side), right after the first mention of the character in that shot."
"side), right after the first mention of them in that shot. Leave the later "
"shots alone - they use the bare label and must NOT repeat the outfit."
)
if location_misses:
lock_lines = "\n".join(f" {n}: {', '.join(a)}" for n, a in location_locks.items())
@@ -429,7 +605,7 @@ def subset_repair_prompt(wardrobe_locks, wardrobe_misses, location_locks,
def enforce_continuity(enabled, synopsis, parsed, scene_count, scene_duration,
session_id, info, repair):
session_id, info, repair, cast=()):
"""
Shared post-check for the multi-scene writers: verify the wardrobe lock per
shot and the location lock per scene; if anything is missing and a session
@@ -437,7 +613,9 @@ def enforce_continuity(enabled, synopsis, parsed, scene_count, scene_duration,
and keep the repaired answer only if it still parses to the same scene count.
Returns (synopsis, parsed, info).
"""
w_locks = parse_wardrobe_lock(synopsis)
w_locks, dropped = restrict_to_cast(parse_wardrobe_lock(synopsis), cast_names(cast))
if dropped:
print(f"👔 not cast, so not locked: {', '.join(dropped)}")
l_locks = parse_location_lock(synopsis)
scenes = [p for _, _, p in parsed]
w_miss = wardrobe_violations(scenes, w_locks)
@@ -482,14 +660,16 @@ def enforce_continuity(enabled, synopsis, parsed, scene_count, scene_duration,
)
def enforce_continuity_chunked(enabled, synopsis, parsed, session_id, info, repair):
def enforce_continuity_chunked(enabled, synopsis, parsed, session_id, info, repair, cast=()):
"""
Post-check for runs written in chunks (music videos: 13-20 scenes), where a
full re-emit is too long to ask for: verify every scene against the synopsis
locks, then use ONE repair turn to re-emit only the violating scenes and
splice them back in by number. Returns (parsed, info).
"""
w_locks = parse_wardrobe_lock(synopsis)
w_locks, dropped = restrict_to_cast(parse_wardrobe_lock(synopsis), cast_names(cast))
if dropped:
print(f"👔 not cast, so not locked: {', '.join(dropped)}")
l_locks = parse_location_lock(synopsis)
scenes = [p for _, _, p in parsed]
w_miss = wardrobe_violations(scenes, w_locks)
+152 -78
View File
@@ -22,6 +22,9 @@
# * builds a sustained upward loudness ramp that lands on a drop.
# * sections the same novelty over a 4 s window - verse/chorus turns.
#
# What the picture should DO about any of it is not decided here. This node
# reports what the music does and when; the writer stages it.
#
# Offline changes two things for the better: the median window can be CENTRED
# on each frame instead of trailing it, and every threshold is relative to the
# whole track rather than to whatever has played so far, so the first bar is
@@ -106,47 +109,39 @@ _SOUND = {
),
}
# What to put ON the moment, per type - stated ONCE, in the table's key and in
# the writer's directives, never per line. At 120 events the same sentence
# repeated on every row is most of the prompt budget and none of the
# information.
# How long BEFORE the sound the picture has to START moving, per kind, at
# (light, solid, heavy).
#
# Deliberately EFFECTS-first. A 9-second H3 clip can rarely afford a cut - one
# shot is usually the whole clip - so the picture has to MOVE on the beat
# instead: a shockwave, a gust, a blast, dust jumping, light snapping off.
# Those are things a video model can actually render on a named second, and
# they are what "sync the picture to the music" means when cutting is off the
# table.
_SYNC = {
"DROP": (
"open the frame on it - a blast of light, a shockwave rolling out through "
"dust or water, wind slamming in, the camera released into motion, a crowd "
"erupting, everything that was still now moving"
),
"STOP": (
"empty or still the frame - motion freezes, the wind dies, dust hangs in "
"the air, lights snap off, a held breath, sudden silence made visible"
),
"SECTION": (
"change the world - new location, new light, new lens, new weather; the "
"look of the shot turns here and does not turn back"
),
"BUILD": (
"tighten toward the drop - the push-in accelerates, hair and dust lift, "
"wind rises, strobes quicken, the frame closing in as the pressure climbs"
),
"IMPACT": (
"a hard physical event - something strikes, a blast, glass, sparks, a body "
"landing, a shockwave ring punched outward, the camera shaken by it"
),
"BASS HIT": (
"one visible pulse - a step or a stomp, dust jumping off the floor, a light "
"throb, a ripple ring across water, fabric snapping, the ground answering"
),
"ACCENT": (
"a small bright accent - a glint, a spark, a lens flare crossing frame, a "
"flick of the eyes; an accent, never a cut"
),
# This is the whole reason a "synced" video reads out of sync. What a viewer
# perceives as the moment of an effect is its PEAK, not its first frame - so a
# move told to begin ON the beat peaks a fifth of a second late, on every hit,
# in every clip, and the finished video drifts off the music even though the
# audio is sample-aligned. Every event therefore goes to the writer as a
# WINDOW - start the move here, land it on the beat, settle it by there - and
# these are the lead times. Ordinary animation anticipation, scaled by how big
# the sound is: a cymbal tick needs two frames of warning, a full drop needs a
# held breath.
_LEAD = {
"DROP": (0.55, 0.85, 1.20),
"STOP": (0.40, 0.60, 0.85),
"SECTION": (0.30, 0.40, 0.55),
"BUILD": (0.90, 1.40, 2.00),
"IMPACT": (0.32, 0.45, 0.60),
"BASS HIT": (0.20, 0.28, 0.36),
"ACCENT": (0.08, 0.12, 0.16),
}
# ...and how long AFTER it the aftermath runs. Dust does not stop in the air on
# the frame after the kick, and a move that ends the instant it lands is the
# other half of looking mechanical.
_TAIL = {
"DROP": (0.55, 0.80, 1.10),
"STOP": (0.50, 0.75, 1.10),
"SECTION": (0.35, 0.45, 0.60),
"BUILD": (0.0, 0.0, 0.0), # a build's follow-through IS the drop
"IMPACT": (0.35, 0.50, 0.70),
"BASS HIT": (0.22, 0.30, 0.40),
"ACCENT": (0.10, 0.14, 0.18),
}
@@ -164,16 +159,32 @@ def _sound_note(kind, strength):
return tiers[_strength_tier(strength)] if tiers else ""
def sync_key_lines(events, indent=" "):
"""
The staging key for whichever event kinds actually occur in `events`.
def _tiered(table, kind, strength):
"""The (light, solid, heavy) value for one event, or 0 for an unknown kind."""
tiers = table.get(kind)
return tiers[_strength_tier(strength)] if tiers else 0.0
Only the kinds present: a key that explains STOP to a track that never
stops is teaching the model about a moment it will never be asked to
stage, and it will find somewhere to use it anyway.
def event_window(event):
"""
present = [k for k in EVENT_TYPES if any(e.get("type") == k for e in events)]
return [f"{indent}{k:<9} {_SYNC[k]}" for k in present if _SYNC.get(k)]
(start, land, settle) in absolute song seconds for one event.
`land` is the second the sound is on. `start` is when the picture has to
begin moving for its peak to arrive there, and `settle` is where the
aftermath finishes. A BUILD needs no guessed lead: the detector already
knows the drop it ramps into, so its window is the real ramp.
"""
kind = event.get("type", "")
strength = float(event.get("strength", 0.5))
land = float(event.get("t", 0.0))
until = event.get("until")
if kind == "BUILD" and until is not None:
return land, float(until), float(until)
return (
max(0.0, land - _tiered(_LEAD, kind, strength)),
land,
land + _tiered(_TAIL, kind, strength),
)
# ----------------------------------------------------------------------
@@ -626,12 +637,23 @@ def detect_events(
"strength": round(float(s), 2),
"label": _strength_label(s),
"note": _sound_note(kind, s),
"sync": _SYNC.get(kind, ""),
}
for t, kind, s in found
if float(t) <= duration and float(s) >= min_strength or kind in _STRUCTURAL
]
# A BUILD's own `t` is where the ramp STARTS - the quietest point of the
# run-up - so on its own it says nothing about where the pressure is meant
# to be released. Pair it with the drop it climbs into and the writer gets
# the real window instead of a guessed lead.
drop_times = sorted(e["t"] for e in events if e["type"] == "DROP")
for e in events:
if e["type"] != "BUILD":
continue
landing = next((d for d in drop_times if d > e["t"] + 1e-6), None)
if landing is not None:
e["until"] = round(float(landing), 2)
cap = max(1, int(max_events))
if len(events) > cap:
keep = [e for e in events if e["type"] in _STRUCTURAL]
@@ -650,16 +672,19 @@ def detect_events(
# ----------------------------------------------------------------------
_LINE_RE = re.compile(
r"^\s*\[(\d+):(\d{2}(?:\.\d+)?)\]\s+([A-Z][A-Z ]*[A-Z]|[A-Z])\s*(?:\|\s*(\w+))?\s*(?:\|\s*(.*))?$"
r"^\s*\[(\d+):(\d{2}(?:\.\d+)?)\]\s+([A-Z][A-Z ]*[A-Z]|[A-Z])\s*"
# optional landing time: a BUILD carries the drop it ramps into
r"(?:->\s*\[(\d+):(\d{2}(?:\.\d+)?)\]\s*)?"
r"(?:\|\s*(\w+))?\s*(?:\|\s*(.*))?$"
)
def events_table(events, duration=0.0, profile=None, key=True):
def events_table(events, duration=0.0, profile=None):
"""
The readable, re-parseable table. One event per line, absolute times.
The `#` header carries the staging key once, for the kinds that occur. It
is a comment, so `parse_events` skips it and the table still round-trips.
What the picture should DO about each moment is deliberately not here. The
writer decides that; this only reports what the music does and when.
"""
lines = []
if profile:
@@ -667,13 +692,12 @@ def events_table(events, duration=0.0, profile=None, key=True):
lines.append(f"# {profile_line(profile)}")
if duration:
lines.append(f"# {fmt_time(duration)} of audio, {len(events)} event(s)")
if key and events:
keyed = sync_key_lines(events, indent="# ")
if keyed:
lines.append("# SYNC KEY - what the picture should do on each kind of moment:")
lines.extend(keyed)
for e in events:
row = f"[{fmt_time(e['t'])}] {e['type']:<9} | {e['label']}"
row = f"[{fmt_time(e['t'])}] {e['type']:<9}"
if e.get("until") is not None:
# where the ramp is released - a build is a window, not an instant
row += f" -> [{fmt_time(e['until'])}]"
row += f" | {e['label']}"
if e.get("note"):
row += f" | {e['note']}"
lines.append(row)
@@ -702,16 +726,17 @@ def parse_events(text):
if not isinstance(e, dict) or "t" not in e:
continue
strength = float(e.get("strength", 0.5))
out.append({
row = {
"t": float(e["t"]),
"type": str(e.get("type", "EVENT")).upper(),
"strength": strength,
"label": str(e.get("label") or _strength_label(strength)),
"note": str(e.get("note") or _sound_note(
str(e.get("type", "")).upper(), strength)),
"sync": str(e.get("sync") or _SYNC.get(
str(e.get("type", "")).upper(), "")),
})
}
if e.get("until") is not None:
row["until"] = float(e["until"])
out.append(row)
out.sort(key=lambda e: e["t"])
return out
except (ValueError, TypeError):
@@ -724,32 +749,47 @@ def parse_events(text):
m = _LINE_RE.match(line)
if not m:
continue
minutes, seconds, kind, label, note = m.groups()
minutes, seconds, kind, until_min, until_sec, label, note = m.groups()
kind = kind.strip()
strength = {"light": 0.2, "solid": 0.5, "heavy": 0.9}.get((label or "").lower(), 0.5)
out.append({
row = {
"t": int(minutes) * 60 + float(seconds),
"type": kind,
"strength": strength,
"label": (label or "solid").lower(),
"note": (note or "").strip() or _sound_note(kind, strength),
# never written per row - the table carries it once, in the key
"sync": _SYNC.get(kind, ""),
})
}
if until_min is not None:
row["until"] = int(until_min) * 60 + float(until_sec)
out.append(row)
out.sort(key=lambda e: e["t"])
return out
def events_for_segment(events, start, end, limit=8):
"""
The events inside one clip, timed FROM THE CLIP'S OWN START.
The events this clip has to stage, timed FROM THE CLIP'S OWN START.
That relative time is the whole point: the writer is describing a 9-second
shot, and "a bass hit at +2.1 s" is something it can stage, while "a bass
hit at 1:47.3" is not. Capped per clip, strongest first, so one busy bar
cannot swamp a scene brief - then re-sorted into time order.
A clip's events are not simply the ones inside it. A drop landing a third
of a second after a cut needs most of a second of wind-up, and all of that
wind-up belongs to the OUTGOING clip - which, listing only its own hits,
would never hear about it and would end at rest. So an event also counts as
this clip's if its window STARTS here, even though it lands in the next
one; the outgoing clip climbs into it without resolving, and the incoming
clip (which lists the same event, opening mid-move) releases it. That
hand-off across the cut is the difference between a video that hits the
drop and one that arrives just after it.
"""
inside = [e for e in events if start <= e["t"] < end]
inside = [
e for e in events
if start <= e["t"] < end
or (e["t"] >= end and event_window(e)[0] < end - 1e-6)
]
if len(inside) > limit:
ranked = sorted(
inside,
@@ -757,22 +797,56 @@ def events_for_segment(events, start, end, limit=8):
)
inside = ranked[:limit]
inside.sort(key=lambda e: e["t"])
return [dict(e, offset=round(e["t"] - start, 2)) for e in inside]
span = max(0.0, end - start)
out = []
for e in inside:
cue, land, settle = event_window(e)
out.append(dict(
e,
offset=round(e["t"] - start, 2),
# the staging window, clamped into the clip: nothing outside these
# bounds exists as far as this render is concerned
cue_offset=round(min(span, max(0.0, cue - start)), 2),
land_offset=round(min(span, max(0.0, land - start)), 2),
settle_offset=round(min(span, max(0.0, settle - start)), 2),
# ...but the fact that it was clamped is itself a staging note. A
# wind-up that began in the previous clip means this one opens
# mid-move, and a peak past the end means this one must not resolve.
opens_wound_up=cue < start - 1e-6,
lands_after=land > end + 1e-6,
))
# Ordered by where the MOVE starts, not where the sound is. The writer
# turns this list into one chronological run of clauses, and the first
# clause of every event is its wind-up - so a drop whose pressure starts
# building before an earlier kick has to be read, and written, first.
out.sort(key=lambda e: (e["cue_offset"], e["land_offset"]))
return out
def segment_event_lines(events, start, end, limit=8):
"""
`events_for_segment` as the indented `[+s]` lines the writer's brief uses.
`events_for_segment` as the indented window lines the writer's brief uses.
Same three columns as the table so the two read as one format. The note
rides along - it is per-event and sized - while the sync guidance does
not: that is per KIND, and the writer states it once in its directives.
THREE numbers, not one - start the move / land it / settle it - all timed
from the clip's own start. Handing over the bare instant is what makes a
"synced" video read late: a move told to begin on the beat peaks after it.
Type and size only. WHAT lands on the moment is the writer's decision, not
a lookup - the plain-words note the table carries would just be a stronger
hint toward the same handful of images in every scene.
"""
lines = []
for e in events_for_segment(events, start, end, limit):
note = e.get("note") or _sound_note(e.get("type", ""), e.get("strength", 0.5))
row = f" [+{e['offset']:5.2f}s] {e['type']:<9} | {e['label']}"
lines.append(f"{row} | {note}" if note else row)
row = (
f" [+{e['cue_offset']:5.2f} ->+{e['land_offset']:5.2f}"
f" ->+{e['settle_offset']:5.2f}s] {e['type']:<9} | {e['label']}"
)
if e.get("opens_wound_up"):
row += " <opens already moving>"
if e.get("lands_after"):
row += " <lands in the next clip>"
lines.append(row)
return lines