359 lines
11 KiB
Python
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
|