Removing the narration_combined

This commit is contained in:
2026-07-15 18:57:11 +02:00
parent ce9c35c9b2
commit eb9196e50d
8 changed files with 267 additions and 187 deletions
+100 -118
View File
@@ -4221,6 +4221,42 @@ def _chunked_render(
return 0
def _build_merged_transcription(project_path: Path, config, verbose: bool = False):
"""Deterministic merged transcript for slide alignment.
Builds a single word-level transcript from the per-segment transcripts +
the current narration.json skip/take, re-timed into the combined timeline
(see narration.build_narration_schedule). This keeps alignment in sync with
narration.json and avoids re-transcribing the combined file.
Returns None (caller falls back to the on-disk transcript) when there are no
narration segments or any segment is missing its per-segment transcript.
"""
from .parser import parse_narration, get_video_duration
from .narration import build_narration_schedule
try:
narration, narration_dir = parse_narration(project_path, config)
except GnommoError:
return None
if not narration:
return None
transcripts_dir = narration_dir / "transcripts"
missing = [sid for sid in narration if not (transcripts_dir / f"{sid}.json").exists()]
if missing:
if verbose:
print(f" Merged transcript unavailable (no per-segment transcript for: "
f"{', '.join(missing)}) — using on-disk transcript.")
return None
_segments, merged = build_narration_schedule(
narration, narration_dir, get_video_duration,
transcripts_dir=transcripts_dir, verbose=verbose,
)
return merged or None
def cmd_render(
project_path: Path,
verbose: bool,
@@ -4303,129 +4339,59 @@ def cmd_render(
print(f" Using {res} dir: {videos_dir}")
audio, audio_dir = parse_audio(project_path, config)
# Load whisper transcription JSON
# Resolve the combined narration skeleton. The .mov file — not the videos.json
# entry — is the source of truth: stitch run without the external drive leaves
# the file in media/videos/ even when the videos.json entry is absent (e.g. a
# metadata pull overwrote it). Legacy multi-segment projects are handled below.
combined_path = videos_dir / "narration_combined.mov"
resolved_combined = _resolve_narration_combined(project_path, videos_dir, config)
narration_json = project_path / "media" / "narration" / "narration.json"
_narr_segments = _read_json(narration_json) if narration_json.exists() else {}
if resolved_combined and resolved_combined.exists():
# File is available (locally or via process cache). Ensure a videos.json
# entry exists — synthesizing one when stitch's entry was lost — then use it.
if "narration_combined" not in videos:
from .models import VideoSource
# --- Narration: render-time concat of the processed segments ---
# narration.json is the single source of truth. The processed segments are
# concatenated directly in the render graph — there is no narration_combined
# file anymore.
from .narration import build_narration_schedule
from .parser import parse_narration as _parse_narr, get_video_duration
_first_seg = next(iter(_narr_segments.values()), {})
_seg_cutout = (
_first_seg.get("cutout") if isinstance(_first_seg, dict) else None
)
videos["narration_combined"] = VideoSource(
source_file="narration_combined.mov",
cutout=_seg_cutout or "talkinghead",
always_visible=True,
volume=1.0,
)
if resolved_combined != combined_path:
# File lives on external disk — point the VideoSource at the absolute
# path so the renderer doesn't re-resolve it via the local videos_dir.
videos["narration_combined"].source_file = str(resolved_combined)
transcript_path = resolved_combined.with_suffix(".transcript.json")
config.main_video = "narration_combined"
if verbose:
narration_map, narration_seg_dir = _parse_narr(project_path, config)
narration_schedule: list = []
narration_source = None
transcript_path = None
if narration_map:
narration_schedule, _ = build_narration_schedule(
narration_map, narration_seg_dir, get_video_duration
)
missing = [s.seg_id for s in narration_schedule if not s.source_path.exists()]
if missing:
print(
f" Using combined narration: {resolved_combined.name} (volume={videos['narration_combined'].volume})"
)
elif isinstance(config.main_video, list) and len(config.main_video) > 1:
# Legacy: Multi-segment narration with main_video array in project.json
resolved_combined, _ = resolve_with_cache(combined_path, project_path)
transcript_path = resolved_combined.with_suffix(".transcript.json")
if not resolved_combined.exists():
print(
f"Error: Combined narration not found: {combined_path}", file=sys.stderr
)
print(
"Run 'gnommo -p <project> concat' first to concatenate segments.",
f"Error: processed narration segment(s) not found: {', '.join(missing)}",
file=sys.stderr,
)
print(f"Run 'gnommo -p {project_path.name} preprocess' first.", file=sys.stderr)
return 1
# Talking-head cutout/zoom/audio settings come from the first segment.
narration_source = narration_map[narration_schedule[0].seg_id]
# Create a synthetic video entry for the combined narration
# Inherit settings from the first segment
first_segment_id = config.main_video[0]
if first_segment_id in videos:
first_segment = videos[first_segment_id]
from .models import VideoSource
combined_video = VideoSource(
source_file="narration_combined.mov",
filter=first_segment.filter,
output_file=None, # Already processed
cutout=first_segment.cutout,
always_visible=True,
skip=0.0, # Already trimmed during concatenation
take=None,
)
videos["_narration_combined"] = combined_video
config.main_video = "_narration_combined"
# --- Transcript for slide alignment ---
# Prefer the deterministic merged transcript (per-segment transcripts re-timed
# into the concatenated timeline). Otherwise fall back to an on-disk transcript.
transcription = _build_merged_transcription(project_path, config, verbose)
if transcription is not None:
if verbose:
print(f" Using combined narration: {combined_path.name}")
elif _narr_segments:
# narration.json has segments, but the combined .mov could not be found.
# Distinguish "stitched, file unreachable" from "never stitched" so the
# hint is actionable instead of always blaming videos.json.
if "narration_combined" in videos:
print(
f"Error: narration_combined.mov could not be found.", file=sys.stderr
)
print(
f"videos.json references narration_combined, but the file is not on disk "
f"(checked local media/videos and the process cache).",
file=sys.stderr,
)
print(_narration_combined_hint(project_path, config), file=sys.stderr)
else:
print(
f"Error: narration_combined not found in videos.json", file=sys.stderr
)
print(
f"You have narration segments in narration.json but haven't stitched them.",
file=sys.stderr,
)
print(
f"Run 'gnommo -p {project_path.name} stitch' first.",
file=sys.stderr,
)
return 1
print(f" Using merged per-segment transcript ({len(transcription)} words)")
else:
# Single video - look for .transcript.json next to the narration video
result = _find_narration_video(config, videos)
if result:
video_id, narration_source = result
config.main_video = video_id # Ensure main_video is set to the found video
video_path = videos_dir / narration_source.source_file
transcript_path = video_path.with_suffix(".transcript.json")
if config.transcript_path and (project_path / config.transcript_path).exists():
transcript_path = project_path / config.transcript_path
elif narration_map:
# Legacy on-disk transcript that used to accompany narration_combined.
transcript_path = videos_dir / "narration_combined.transcript.json"
else:
transcript_path = project_path / "transcript.json"
# If project.json specifies a transcript path, prefer it (always local)
if config.transcript_path:
local_transcript = project_path / config.transcript_path
if local_transcript.exists():
transcript_path = local_transcript
# Try cache fallback for transcript
transcript_path, _ = resolve_with_cache(transcript_path, project_path)
if not transcript_path.exists():
print(f"Error: Transcription not found: {transcript_path}", file=sys.stderr)
print(f"Run 'gnommo -p {project_path.name} transcribe' first.", file=sys.stderr)
return 1
transcription = load_transcript(transcript_path, project_path)
result = _find_narration_video(config, videos)
if result:
_vid, _src = result
config.main_video = _vid
transcript_path = (videos_dir / _src.source_file).with_suffix(".transcript.json")
else:
transcript_path = project_path / "transcript.json"
transcript_path, _ = resolve_with_cache(transcript_path, project_path)
if not transcript_path.exists():
print(f"Error: Transcription not found: {transcript_path}", file=sys.stderr)
print(f"Run 'gnommo -p {project_path.name} transcribe' first.", file=sys.stderr)
return 1
transcription = load_transcript(transcript_path, project_path)
if verbose:
print(f" - Markers in manuscript: {len(markers)}")
@@ -4455,9 +4421,13 @@ def cmd_render(
audio,
audio_dir,
slide_range=slide_range,
narration_schedule=narration_schedule,
narration_source=narration_source,
)
if plan.time_offset > 0:
print(f" Time offset: {plan.time_offset:.1f}s (partial render)")
if plan.narration_segments:
print(f" Narration concat: {len(plan.narration_segments)} segment(s) at render time")
# Print detailed render plan with alignment info
_print_render_plan_details(plan, marker_timings, slides)
@@ -4584,10 +4554,19 @@ def cmd_render(
("manuscript.txt", project_path / "manuscript.txt", _state.HASH),
("project.json", project_path / "project.json", _state.HASH),
("slides.json", _slides_json, _state.HASH),
("transcript", transcript_path, _state.HASH),
# narration.json (skip/take) drives the concat timeline + alignment.
("narration.json", project_path / "media" / "narration" / "narration.json", _state.HASH),
]
if resolved_combined:
specs.append(("narration_combined", resolved_combined, _state.META))
if transcript_path:
specs.append(("transcript", transcript_path, _state.HASH))
# The concatenated narration segments (the render's main video input).
for _seg in plan.narration_segments:
specs.append((f"narr:{_seg.seg_id}", _seg.source_path, _state.META))
# Per-segment transcripts feed the merged transcript for alignment.
_tdir = project_path / "media" / "narration" / "transcripts"
if _tdir.is_dir():
for _tj in sorted(_tdir.glob("*.json")):
specs.append((f"transcript:{_tj.stem}", _tj, _state.HASH))
for _sid, _sdef in slides.items():
specs.append((f"slide:{_sid}", _slides_dir / _sdef.image, _state.META))
return specs
@@ -4606,7 +4585,10 @@ def cmd_render(
if _render_current and output_path.exists():
try:
_out_mtime = output_path.stat().st_mtime
for _dep in (resolved_combined, transcript_path):
_deps = [s.source_path for s in plan.narration_segments]
if transcript_path:
_deps.append(transcript_path)
for _dep in _deps:
if _dep and Path(_dep).exists() and Path(_dep).stat().st_mtime > _out_mtime:
print(f" {Path(_dep).name} is newer than the render — regenerating.")
_render_current = False