diff --git a/gnommo/cli.py b/gnommo/cli.py index fc34c3d..96b233a 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -841,9 +841,22 @@ def _import_shared_audio( existing: dict = _read_json(audio_json_path) if audio_json_path.exists() else {} added = 0 + normalized = 0 for f in audio_files: audio_id = f.stem.lower() if audio_id in existing: + # Backfill the audio schema onto an existing entry: make `loop` + # explicit (so it's not an invisible optional the way it was before), + # and drop `zoom` — a video-only field that means nothing for audio and + # only ended up here from legacy/hand-edited entries. + entry = existing[audio_id] + if isinstance(entry, dict): + if "loop" not in entry: + entry["loop"] = None + normalized += 1 + if "zoom" in entry: + del entry["zoom"] + normalized += 1 if verbose: print(f" Skipping {audio_id} (already in audio.json)") continue @@ -851,16 +864,24 @@ def _import_shared_audio( "file": f.name, "is_shared": True, "volume": 1.0, + # One-shot by default (null → not looping); set true for background + # music/ambience that should loop for the whole video. + "loop": None, } added += 1 if verbose: print(f" Added shared audio: {audio_id}") - if added > 0: + if added > 0 or normalized > 0: with open(audio_json_path, "w", encoding="utf-8") as fh: json.dump(existing, fh, indent=2) + parts = [] + if added: + parts.append(f"+{added} shared audio files") + if normalized: + parts.append(f"{normalized} field(s) normalized") print( - f" Updated {audio_json_path.relative_to(project_path)} (+{added} shared audio files)" + f" Updated {audio_json_path.relative_to(project_path)} ({', '.join(parts)})" ) else: if verbose: diff --git a/gnommo/renderer.py b/gnommo/renderer.py index 7da701b..870f267 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -1544,16 +1544,61 @@ def build_filter_complex( ) audio_labels_to_mix.append(f"[{label}]") else: - # One-shot audio: delay to trigger time. Chunking v2: seek in if - # the clip began in an earlier chunk (docs/chunking_v2.md). + # One-shot audio. Freeze it through narration pauses too (like the + # looping branch above): split the source at each pause and delay + # the remainder, so the clip resumes on the exact sample it stopped + # on when the cutscene ends — it is never restarted. A pause set on + # a cutscene video therefore silences background one-shots for its + # duration. `ignore_pauses` opts a clip out (e.g. a stinger meant to + # keep playing under the freeze). Chunking v2: src_offset seeks in + # when the clip began in an earlier chunk (docs/chunking_v2.md). label = f"aud{i}" - delay_ms = int(event.start_time * 1000) _off = getattr(event, "src_offset", 0.0) - _seek = f"atrim={_off:.3f},asetpts=PTS-STARTPTS," if _off > 0 else "" - filters.append( - f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]" + relevant_pauses = ( + [] + if event.audio_def.ignore_pauses + else [ + p + for p in (plan.narration_pauses or []) + if p.output_time > event.start_time + ] ) - audio_labels_to_mix.append(f"[{label}]") + if not relevant_pauses: + delay_ms = int(event.start_time * 1000) + _seek = f"atrim={_off:.3f},asetpts=PTS-STARTPTS," if _off > 0 else "" + filters.append( + f"[{audio_idx}:a]{_seek}adelay={delay_ms}|{delay_ms},volume={volume:.2f}[{label}]" + ) + audio_labels_to_mix.append(f"[{label}]") + else: + # Play [seg_start, pause) of source, freeze during the pause, + # then resume — source position (src_pos) never advances across + # the gap. Final segment runs to the source's natural end. + src_pos = _off + seg_start = event.start_time + seg_count = 0 + for pause in relevant_pauses: + if pause.output_time > seg_start: + seg_dur = pause.output_time - seg_start + seg_label = f"{label}_seg{seg_count}" + d_ms = int(seg_start * 1000) + filters.append( + f"[{audio_idx}:a]atrim={src_pos:.3f}:{src_pos + seg_dur:.3f}," + f"asetpts=PTS-STARTPTS,adelay={d_ms}|{d_ms}," + f"volume={volume:.2f}[{seg_label}]" + ) + audio_labels_to_mix.append(f"[{seg_label}]") + src_pos += seg_dur + seg_count += 1 + seg_start = pause.output_time + pause.duration + seg_label = f"{label}_seg{seg_count}" + d_ms = int(seg_start * 1000) + filters.append( + f"[{audio_idx}:a]atrim={src_pos:.3f}," + f"asetpts=PTS-STARTPTS,adelay={d_ms}|{d_ms}," + f"volume={volume:.2f}[{seg_label}]" + ) + audio_labels_to_mix.append(f"[{seg_label}]") # Extract and mix audio from triggered video events _have_audio = video_events_with_audio or set()