cast prompt decrease
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user