diff --git a/nodes/h3/claude_code_crossover_writer.py b/nodes/h3/claude_code_crossover_writer.py index fa93087..bccc1d4 100644 --- a/nodes/h3/claude_code_crossover_writer.py +++ b/nodes/h3/claude_code_crossover_writer.py @@ -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 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) diff --git a/nodes/h3/claude_code_music_video_writer.py b/nodes/h3/claude_code_music_video_writer.py index b4bb42f..a150d0f 100644 --- a/nodes/h3/claude_code_music_video_writer.py +++ b/nodes/h3/claude_code_music_video_writer.py @@ -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( + "- `` means the wind-up began before this clip: " + "open on a first frame ALREADY in motion, never from rest. `` 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 `...` 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 `` 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: diff --git a/nodes/h3/claude_code_presentation_writer.py b/nodes/h3/claude_code_presentation_writer.py index 29430b5..da95df7 100644 --- a/nodes/h3/claude_code_presentation_writer.py +++ b/nodes/h3/claude_code_presentation_writer.py @@ -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): diff --git a/nodes/h3/claude_code_short_film_writer.py b/nodes/h3/claude_code_short_film_writer.py index 78847c0..0033ff5 100644 --- a/nodes/h3/claude_code_short_film_writer.py +++ b/nodes/h3/claude_code_short_film_writer.py @@ -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 `` 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): diff --git a/nodes/h3/scenes_store.py b/nodes/h3/scenes_store.py index d82699a..fac86dc 100644 --- a/nodes/h3/scenes_store.py +++ b/nodes/h3/scenes_store.py @@ -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: diff --git a/nodes/h3/scenes_support.py b/nodes/h3/scenes_support.py index d8f70ad..208df35 100644 --- a/nodes/h3/scenes_support.py +++ b/nodes/h3/scenes_support.py @@ -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 `` 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 - - ...`); later shots in the same scene restate the " - "anchors that are in frame. " + _LOCATION_ANCHOR_RULES + + "living room - - ...`). 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 +# , 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 (`` 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 + `` 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 " - "(`... Sheldon (S1), wearing , ...`). " + _ANCHOR_RULES + + "synopsis `Wardrobe:` lines, into that character's `` 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 " + "(`... Sheldon (S1), wearing , ...`). " + _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 (`... Sheldon Cooper (S1), wearing , ...`). " - + _ANCHOR_RULES + "Then copy that character's anchors word-for-word into their `` 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 " + "(`... Sheldon Cooper (S1), wearing , ...`). " + _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 + `` 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) diff --git a/nodes/h3/sound_events.py b/nodes/h3/sound_events.py index f07acd0..ee22697 100644 --- a/nodes/h3/sound_events.py +++ b/nodes/h3/sound_events.py @@ -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 += " " + if e.get("lands_after"): + row += " " + lines.append(row) return lines