Files
gnommo/gnommo/description.py
T
2026-03-26 10:46:05 +01:00

359 lines
11 KiB
Python

"""Description generator: Create YouTube description with chapters, citations, and attributions."""
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from .models import (
Attribution,
Citation,
ProjectConfig,
SlideDefinition,
VideoSource,
)
from .transcriber import TranscribedWord
@dataclass
class ChapterMarker:
"""A chapter marker with timestamp and title."""
slide_id: str
timestamp: float
title: str
def _format_timestamp(seconds: float) -> str:
"""Format seconds as M:SS or H:MM:SS for YouTube chapters."""
if seconds < 0:
return "0:00"
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
else:
return f"{minutes}:{secs:02d}"
def _extract_chapter_title(
manuscript_text: str, slide_id: str, slides: dict[str, SlideDefinition]
) -> str:
"""
Extract a chapter title for a slide.
Tries to find meaningful title from:
1. First sentence/line after the slide marker
2. Falls back to slide ID if nothing useful found
"""
# Find the marker and text after it
pattern = rf"\[{re.escape(slide_id)}\]\s*(.+?)(?=\[S\d+\]|\[video:|\[narration:|\Z)"
match = re.search(pattern, manuscript_text, re.DOTALL)
if match:
text = match.group(1).strip()
# Remove any other markers from the text
text = re.sub(r"\[[^\]]+\]", "", text).strip()
if text:
# Take first line or first sentence
first_line = text.split("\n")[0].strip()
# Truncate if too long
if len(first_line) > 50:
# Try to break at word boundary
truncated = first_line[:47]
last_space = truncated.rfind(" ")
if last_space > 30:
truncated = truncated[:last_space]
first_line = truncated + "..."
if first_line:
return first_line
# Fallback to slide number
slide_num = slide_id[1:] if slide_id.startswith("S") else slide_id
return f"Section {slide_num}"
def _align_citation_to_transcription(
citation: Citation,
transcription: list[TranscribedWord],
manuscript_text: str,
) -> float:
"""
Align a citation to the transcription to find its timestamp.
Uses the context text following the citation to find the approximate
position in the audio.
Returns timestamp in seconds, or -1 if not found.
"""
if not transcription or not citation.context:
return -1.0
# Get more context from the manuscript for better matching
# Find the citation in the manuscript and get surrounding text
pattern = rf"\[cite:{re.escape(citation.reference)}\]\s*(.{{0,200}})"
match = re.search(pattern, manuscript_text, re.DOTALL)
if not match:
return -1.0
context_text = match.group(1).strip()
# Clean up: remove markers, normalize whitespace
context_text = re.sub(r"\[[^\]]+\]", "", context_text)
context_text = " ".join(context_text.split())
if not context_text:
return -1.0
# Normalize for matching
context_words = context_text.lower().split()[:10] # Use up to 10 words
if not context_words:
return -1.0
# Build normalized transcription
trans_words = [(w.word.lower(), w.start) for w in transcription]
# Simple sliding window match
best_match_score = 0
best_match_time = -1.0
for i in range(len(trans_words) - len(context_words) + 1):
matches = 0
for j, ctx_word in enumerate(context_words):
trans_word = trans_words[i + j][0]
# Allow partial matches for longer words
if ctx_word == trans_word:
matches += 1
elif len(ctx_word) >= 4 and (
ctx_word in trans_word or trans_word in ctx_word
):
matches += 0.5
score = matches / len(context_words)
if score > best_match_score and score >= 0.5:
best_match_score = score
best_match_time = trans_words[i][1]
return best_match_time
def generate_chapters(
manuscript_text: str,
slides: dict[str, SlideDefinition],
marker_timings: list, # List of MarkerTiming from transformer
min_chapter_duration: float = 30.0,
) -> list[ChapterMarker]:
"""
Generate chapter markers from slide timings.
Args:
manuscript_text: The manuscript content
slides: Slide definitions
marker_timings: Aligned marker timings from the transformer
min_chapter_duration: Minimum seconds between chapters (merges short ones)
Returns:
List of ChapterMarker objects
"""
chapters = []
# Build timing lookup
timing_lookup = {
t.marker_id: t.timestamp for t in marker_timings if t.timestamp >= 0
}
# Process slides in order
slide_ids = sorted(
[s for s in slides.keys() if s.startswith("S")],
key=lambda x: int(x[1:]) if x[1:].isdigit() else 0,
)
for slide_id in slide_ids:
if slide_id not in timing_lookup:
continue
timestamp = timing_lookup[slide_id]
title = _extract_chapter_title(manuscript_text, slide_id, slides)
if chapters and (timestamp - chapters[-1].timestamp) < min_chapter_duration:
continue # Skip this chapter, previous one covers it
chapters.append(
ChapterMarker(
slide_id=slide_id,
timestamp=timestamp,
title=title,
)
)
# Ensure first chapter starts at 0:00
if chapters and chapters[0].timestamp > 0:
chapters[0] = ChapterMarker(
slide_id=chapters[0].slide_id,
timestamp=0.0,
title=chapters[0].title,
)
return chapters
def collect_attributions(
videos: dict[str, VideoSource],
video_events: list = None,
) -> list[tuple[str, Attribution]]:
"""
Collect all video attributions.
Returns list of (video_id, Attribution) tuples for videos that have attribution.
Only includes videos that are actually used in the project (via video_events)
or videos from shared assets that have attribution.
"""
attributions = []
# Get set of used video IDs from events
used_video_ids = set()
if video_events:
for event in video_events:
used_video_ids.add(event.video_id)
for video_id, video_source in videos.items():
if video_source.attribution:
# Include if used in video or if it's a shared asset
if video_id in used_video_ids or video_source.is_shared:
attributions.append((video_id, video_source.attribution))
return attributions
def generate_description(
config: ProjectConfig,
manuscript_text: str,
slides: dict[str, SlideDefinition],
videos: dict[str, VideoSource],
marker_timings: list,
transcription: list[TranscribedWord] = None,
video_events: list = None,
citations: list[Citation] = None,
include_chapters: bool = True,
include_citations: bool = True,
include_attributions: bool = True,
) -> str:
"""
Generate complete YouTube description.
Combines:
- Video description from project.json
- Chapter markers (optional)
- Citations from manuscript (optional)
- Stock footage attributions (optional)
- Footer from project.json
Returns formatted description text.
"""
sections = []
# 1. Video description
if config.description:
sections.append(config.description.strip())
# 2. Chapters
if include_chapters:
chapters = generate_chapters(manuscript_text, slides, marker_timings)
if chapters:
chapter_lines = ["CHAPTERS", ""]
for ch in chapters:
chapter_lines.append(f"{_format_timestamp(ch.timestamp)} {ch.title}")
sections.append("\n".join(chapter_lines))
# 3. Citations/References
if include_citations:
citations = citations or []
if citations and transcription:
# Align citations to get timestamps
for citation in citations:
citation.timestamp = _align_citation_to_transcription(
citation, transcription, manuscript_text
)
if citations:
ref_lines = ["REFERENCES", ""]
for citation in citations:
if citation.timestamp >= 0:
ref_lines.append(
f"{_format_timestamp(citation.timestamp)} - {citation.reference}"
)
else:
ref_lines.append(f"- {citation.reference}")
sections.append("\n".join(ref_lines))
# 4. Stock footage attributions
if include_attributions:
attributions = collect_attributions(videos, video_events)
if attributions:
attr_lines = ["STOCK FOOTAGE", ""]
for video_id, attr in attributions:
# Format: "Description by Creator via Source: URL"
line = f"{video_id.replace('_', ' ').title()} by {attr.creator} via {attr.source.title()}"
if attr.url:
line += f": {attr.url}"
attr_lines.append(line)
sections.append("\n".join(attr_lines))
# 5. Footer
if config.footer:
sections.append(config.footer.strip())
# Join sections with double newlines
return "\n\n".join(sections)
def write_description_file(
output_path: Path,
config: ProjectConfig,
manuscript_text: str,
slides: dict[str, SlideDefinition],
videos: dict[str, VideoSource],
marker_timings: list,
transcription: list[TranscribedWord] = None,
video_events: list = None,
citations: list[Citation] = None,
) -> str:
"""
Generate and write YouTube description to file.
Args:
output_path: Path to write description (e.g., out/description_youtube.txt)
config: Project configuration
manuscript_text: Manuscript content
slides: Slide definitions
videos: Video definitions
marker_timings: Aligned marker timings
transcription: Word-level transcription (optional, for citation timestamps)
video_events: Video events from render plan (optional, for attribution filtering)
citations: Pre-extracted citations (optional, loaded from citations.json)
Returns:
The generated description text
"""
description = generate_description(
config=config,
manuscript_text=manuscript_text,
slides=slides,
videos=videos,
marker_timings=marker_timings,
transcription=transcription,
video_events=video_events,
citations=citations,
)
# Ensure output directory exists
output_path.parent.mkdir(parents=True, exist_ok=True)
# Write description
output_path.write_text(description, encoding="utf-8")
return description