Refactor CLI and add preprocessing pipeline

- New CLI structure: -p project, -a action (required flags)
- Add -i import, -f force, -v verbose, --dry-run, --no-cache options
- Add preprocessor.py with chroma key filter (ProRes 4444 output)
- Support background images from shared_assets folder
- Support video metadata JSON files (talkinghead.json)
- Add validation for preprocessed output before render
- Update gnommo.sh with import command and new CLI interface
- Fix Python 3.9 compatibility (Optional[] instead of | None)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 15:45:19 +01:00
co-authored by Claude Opus 4.5
parent df900dfd59
commit 93fa820275
9 changed files with 763 additions and 287 deletions
+338 -228
View File
@@ -8,18 +8,11 @@ from pathlib import Path
from . import __version__
from .errors import GnommoError, ParseError, ValidationError, RenderError
from .parser import (
parse_manuscript,
parse_project_config,
parse_slides,
parse_transcript,
parse_videos,
)
from .validator import validate_project
from .transformer import build_render_plan
from .renderer import render, generate_ffmpeg_command_string
from .transcriber import transcribe_video, save_transcript, load_transcript
from .aligner import align_markers, save_aligned_transcript
class NotImplementedException(GnommoError):
"""Feature not yet implemented."""
pass
def main() -> int:
@@ -34,120 +27,79 @@ def main() -> int:
version=f"%(prog)s {__version__}",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# validate command
validate_parser = subparsers.add_parser(
"validate",
help="Validate project without rendering",
# Required arguments
parser.add_argument(
"-p", "--project",
type=str,
required=True,
help="Project name (directory in current folder)",
)
validate_parser.add_argument(
"project",
type=Path,
help="Path to project directory",
parser.add_argument(
"-a", "--action",
type=str,
choices=["validate", "preprocess", "render", "all", "transcribe", "align"],
required=True,
help="Action to perform",
)
# render command
render_parser = subparsers.add_parser(
"render",
help="Render video from project",
# Optional arguments
parser.add_argument(
"-i", "--import",
dest="import_assets",
action="store_true",
help="Import assets and generate metadata JSON files",
)
render_parser.add_argument(
"project",
type=Path,
help="Path to project directory",
)
render_parser.add_argument(
"-o", "--output",
type=Path,
help="Output file path (default: project/out/final.mp4)",
)
render_parser.add_argument(
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Print FFmpeg command",
help="Verbose output",
)
render_parser.add_argument(
parser.add_argument(
"-f", "--force",
action="store_true",
help="Force destructive changes (overwrite existing files)",
)
parser.add_argument(
"--no-cache",
action="store_true",
help="Force cache break (not implemented)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print FFmpeg command without executing",
)
# generate-slides command
gen_slides_parser = subparsers.add_parser(
"generate-slides",
help="Generate slides.json from Keynote export folder",
)
gen_slides_parser.add_argument(
"directory",
type=Path,
help="Path to slides directory (e.g., media/slides/Video1)",
)
gen_slides_parser.add_argument(
"--type",
default="square",
help="Slide type for all slides (default: square)",
)
# transcribe command
transcribe_parser = subparsers.add_parser(
"transcribe",
help="Transcribe video audio using Whisper",
)
transcribe_parser.add_argument(
"video",
type=Path,
help="Path to video file",
)
transcribe_parser.add_argument(
"-o", "--output",
type=Path,
help="Output JSON file (default: <video>.transcript.json)",
)
transcribe_parser.add_argument(
"--model",
default="base",
choices=["tiny", "base", "small", "medium", "large"],
help="Whisper model size (default: base)",
)
# align command
align_parser = subparsers.add_parser(
"align",
help="Align manuscript markers to transcript timestamps",
)
align_parser.add_argument(
"project",
type=Path,
help="Path to project directory",
)
align_parser.add_argument(
"--transcript",
type=Path,
help="Path to transcript JSON (default: media/talking_head.transcript.json)",
)
align_parser.add_argument(
"--offset",
type=float,
default=-1.0,
help="Seconds to offset marker times (default: -1.0)",
help="Show what would be done without executing",
)
args = parser.parse_args()
# Resolve project path
project_path = Path(args.project)
if not project_path.is_absolute():
project_path = Path.cwd() / project_path
try:
if args.command == "validate":
return cmd_validate(args.project)
elif args.command == "render":
output = args.output or (args.project / "out" / "final.mp4")
return cmd_render(args.project, output, args.verbose, args.dry_run)
elif args.command == "generate-slides":
return cmd_generate_slides(args.directory, args.type)
elif args.command == "transcribe":
output = args.output or args.video.with_suffix(".transcript.json")
return cmd_transcribe(args.video, output, args.model)
elif args.command == "align":
return cmd_align(args.project, args.transcript, args.offset)
# Check for --no-cache
if args.no_cache:
raise NotImplementedException("--no-cache is not yet implemented")
# Handle import mode
if args.import_assets:
return cmd_import(project_path, args.force, args.verbose)
# Handle actions
if args.action == "validate":
return cmd_validate(project_path, args.verbose)
elif args.action == "preprocess":
return cmd_preprocess(project_path, args.verbose, args.dry_run)
elif args.action == "render":
return cmd_render(project_path, args.verbose, args.dry_run)
elif args.action == "transcribe":
return cmd_transcribe(project_path, args.verbose)
elif args.action == "align":
return cmd_align(project_path, args.verbose)
elif args.action == "all":
return cmd_all(project_path, args.verbose, args.dry_run)
except GnommoError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
@@ -158,9 +110,109 @@ def main() -> int:
return 0
def cmd_validate(project_path: Path) -> int:
"""Run validation only."""
print(f"Validating project: {project_path}")
# =============================================================================
# Import Command
# =============================================================================
def cmd_import(project_path: Path, force: bool, verbose: bool) -> int:
"""Import assets and generate metadata JSON files."""
print(f"Importing assets for: {project_path.name}")
if not project_path.exists():
print(f"Error: Project directory not found: {project_path}", file=sys.stderr)
return 1
# Check for existing files that would be overwritten
slides_base = project_path / "media" / "slides"
slides_dirs = [d for d in slides_base.glob("*/") if d.is_dir()] if slides_base.exists() else []
videos_json = project_path / "videos.json"
files_to_create = []
# Check for slide directories to import
for slides_dir in slides_dirs:
slides_json = slides_dir / "slides.json"
if slides_json.exists() and not force:
print(f"Warning: {slides_json} already exists. Use -f to overwrite.")
return 1
files_to_create.append(("slides", slides_dir))
if not force and files_to_create:
print("\nThe following files will be created/overwritten:")
for ftype, fpath in files_to_create:
print(f" - {fpath}/slides.json")
print("\nUse -f/--force to proceed.")
return 1
# Generate slides.json for each directory
for ftype, slides_dir in files_to_create:
if ftype == "slides":
_generate_slides_json(slides_dir, verbose)
print("Import complete.")
return 0
def _generate_slides_json(directory: Path, verbose: bool) -> None:
"""Generate slides.json from Keynote export folder."""
extensions = {".png", ".gif", ".pdf", ".jpg", ".jpeg"}
files = [f for f in directory.iterdir() if f.suffix.lower() in extensions]
if not files:
print(f" Warning: No image files in {directory}")
return
# Extract numeric suffix from filenames like "Video1.001.png"
pattern = re.compile(r"\.(\d+)\.[^.]+$")
slides = {}
for file in files:
match = pattern.search(file.name)
if match:
num = int(match.group(1))
slide_id = f"S{num}"
slides[slide_id] = {
"image": file.name,
"type": "fullscreen",
}
if not slides:
print(f" Warning: No valid slide files in {directory}")
return
# Sort by slide number
sorted_slides = dict(sorted(slides.items(), key=lambda x: int(x[0][1:])))
# Write slides.json
output_path = directory / "slides.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(sorted_slides, f, indent=2)
print(f" Generated {output_path} ({len(sorted_slides)} slides)")
if verbose:
for slide_id in sorted_slides:
print(f" [{slide_id}]")
# =============================================================================
# Validate Command
# =============================================================================
def cmd_validate(project_path: Path, verbose: bool) -> int:
"""Validate project configuration."""
from .parser import (
parse_manuscript,
parse_project_config,
parse_slides,
parse_videos,
)
from .validator import validate_project
print(f"Validating: {project_path.name}")
if not (project_path / "project.json").exists():
print(f"Error: project.json not found in {project_path}", file=sys.stderr)
return 1
# Parse all files
_, markers, malformed = parse_manuscript(project_path)
@@ -168,6 +220,11 @@ def cmd_validate(project_path: Path) -> int:
slides = parse_slides(project_path, config)
videos = parse_videos(project_path)
if verbose:
print(f" - Markers in manuscript: {len(markers)}")
print(f" - Slides defined: {len(slides)}")
print(f" - Videos defined: {len(videos)}")
# Validate
validate_project(project_path, markers, config, slides, videos, malformed)
@@ -175,140 +232,155 @@ def cmd_validate(project_path: Path) -> int:
return 0
def cmd_render(project_path: Path, output_path: Path, verbose: bool, dry_run: bool) -> int:
"""Run full render pipeline."""
print(f"Rendering project: {project_path}")
print(f"Output: {output_path}")
print()
# =============================================================================
# Preprocess Command
# =============================================================================
# Stage 1: Extract
print("Stage 1/4: Parsing input files...")
def cmd_preprocess(project_path: Path, verbose: bool, dry_run: bool) -> int:
"""Run preprocessing pipeline on video sources."""
from .parser import parse_project_config, parse_videos
from .preprocessor import preprocess_video
print(f"Preprocessing: {project_path.name}")
config = parse_project_config(project_path)
videos = parse_videos(project_path)
for video_id, video_source in videos.items():
print(f"\n Processing: {video_id}")
if not video_source.preprocess:
print(" No preprocessing steps defined, skipping.")
continue
if dry_run:
print(f" Would preprocess: {video_source.file}")
for step in video_source.preprocess:
print(f" - {step}")
else:
preprocess_video(project_path, video_id, video_source, verbose)
print("\nPreprocessing complete.")
return 0
# =============================================================================
# Render Command
# =============================================================================
def cmd_render(project_path: Path, verbose: bool, dry_run: bool) -> int:
"""Render final video."""
from .parser import (
parse_manuscript,
parse_project_config,
parse_slides,
parse_transcript,
parse_videos,
)
from .validator import validate_project
from .transformer import build_render_plan
from .renderer import render, generate_ffmpeg_command_string
print(f"Rendering: {project_path.name}")
# Stage 1: Parse
print("\n[1/4] Parsing...")
_, markers, malformed = parse_manuscript(project_path)
config = parse_project_config(project_path)
slides = parse_slides(project_path, config)
videos = parse_videos(project_path)
transcript = parse_transcript(project_path)
print(f" - Found {len(markers)} slide markers in manuscript")
print(f" - Found {len(slides)} slide definitions")
print(f" - Found {len(transcript)} transcript entries")
print()
if verbose:
print(f" - Markers: {len(markers)}")
print(f" - Slides: {len(slides)}")
print(f" - Transcript entries: {len(transcript)}")
# Stage 2: Validate
print("Stage 2/4: Validating...")
print("\n[2/4] Validating...")
validate_project(project_path, markers, config, slides, videos, malformed)
print(" - Validation passed")
print()
print(" Passed.")
# Stage 3: Transform
print("Stage 3/4: Building render plan...")
print("\n[3/4] Building render plan...")
plan = build_render_plan(project_path, config, slides, videos, transcript)
print(f" - Video duration: {plan.total_duration:.2f}s")
print(f" - Duration: {plan.total_duration:.1f}s")
print(f" - Slide events: {len(plan.slide_events)}")
for event in plan.slide_events:
print(f" - [{event.slide_id}] {event.start_time:.2f}s - {event.end_time:.2f}s")
print()
if verbose:
for event in plan.slide_events:
print(f" [{event.slide_id}] {event.start_time:.1f}s - {event.end_time:.1f}s")
# Stage 4: Render
output_path = project_path / "out" / "final.mp4"
if dry_run:
print("Stage 4/4: Generating FFmpeg command (dry run)...")
print()
print("\n[4/4] FFmpeg command (dry run):")
print(generate_ffmpeg_command_string(plan, output_path))
return 0
print("Stage 4/4: Rendering video...")
print("\n[4/4] Rendering...")
render(plan, output_path, verbose=verbose)
print(f" - Output written to: {output_path}")
print()
print("Done.")
print(f" Output: {output_path}")
print("\nDone.")
return 0
def cmd_generate_slides(directory: Path, slide_type: str) -> int:
"""Generate slides.json from Keynote export folder."""
directory = directory.resolve()
# =============================================================================
# Transcribe Command
# =============================================================================
if not directory.exists():
print(f"Error: Directory not found: {directory}", file=sys.stderr)
return 1
if not directory.is_dir():
print(f"Error: Not a directory: {directory}", file=sys.stderr)
return 1
# Find all image files (png, gif, pdf)
extensions = {".png", ".gif", ".pdf", ".jpg", ".jpeg"}
files = [f for f in directory.iterdir() if f.suffix.lower() in extensions]
if not files:
print(f"Error: No image files found in {directory}", file=sys.stderr)
return 1
# Extract numeric suffix from filenames like "Video1.001.png"
# Pattern: anything followed by .NNN. followed by extension
pattern = re.compile(r"\.(\d+)\.[^.]+$")
slides = {}
for file in files:
match = pattern.search(file.name)
if match:
num = int(match.group(1)) # "001" -> 1
slide_id = f"S{num}"
slides[slide_id] = {
"image": file.name,
"type": slide_type,
}
else:
print(f" Warning: Could not parse slide number from: {file.name}")
if not slides:
print("Error: No valid slide files found", file=sys.stderr)
return 1
# Sort by slide number
sorted_slides = dict(sorted(slides.items(), key=lambda x: int(x[0][1:])))
# Write slides.json in the same directory
output_path = directory / "slides.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(sorted_slides, f, indent=2)
print(f"Generated {output_path}")
print(f" - Found {len(sorted_slides)} slides")
for slide_id, slide_def in sorted_slides.items():
print(f" [{slide_id}] {slide_def['image']}")
return 0
def cmd_transcribe(video_path: Path, output_path: Path, model: str) -> int:
def cmd_transcribe(project_path: Path, verbose: bool) -> int:
"""Transcribe video audio using Whisper."""
print(f"Transcribing: {video_path}")
print(f"Model: {model}")
print()
from .transcriber import transcribe_video, save_transcript
from .parser import parse_videos
words = transcribe_video(video_path, model=model)
print(f"Transcribing: {project_path.name}")
videos = parse_videos(project_path)
if not videos:
print("Error: No videos defined in videos.json", file=sys.stderr)
return 1
# Use first video
video_id = next(iter(videos.keys()))
video_source = videos[video_id]
video_path = project_path / video_source.file
if not video_path.exists():
print(f"Error: Video not found: {video_path}", file=sys.stderr)
return 1
print(f" Video: {video_path.name}")
words = transcribe_video(video_path, model="base")
output_path = video_path.with_suffix(".transcript.json")
save_transcript(words, output_path)
print(f" - Transcribed {len(words)} words")
print(f" - Duration: {words[-1].end:.1f}s" if words else " - No words found")
print(f" - Saved: {output_path}")
save_transcript(words, output_path)
print(f" - Saved to: {output_path}")
# Show first few words as preview
if words:
if verbose and words:
preview = " ".join(w.word for w in words[:10])
print(f" - Preview: {preview}...")
return 0
def cmd_align(project_path: Path, transcript_path: Path = None, offset: float = -1.0) -> int:
# =============================================================================
# Align Command
# =============================================================================
def cmd_align(project_path: Path, verbose: bool) -> int:
"""Align manuscript markers to transcript timestamps."""
print(f"Aligning: {project_path}")
print(f"Offset: {offset}s")
print()
from .transcriber import load_transcript
from .aligner import align_markers, save_aligned_transcript
from .parser import parse_videos
print(f"Aligning: {project_path.name}")
# Load manuscript
manuscript_path = project_path / "manuscript.txt"
@@ -318,45 +390,83 @@ def cmd_align(project_path: Path, transcript_path: Path = None, offset: float =
manuscript_text = manuscript_path.read_text(encoding="utf-8")
# Load transcript
if transcript_path is None:
# Try to find transcript in media folder
transcript_path = project_path / "media" / "talking_head.transcript.json"
# Find transcript
videos = parse_videos(project_path)
video_id = next(iter(videos.keys()))
video_source = videos[video_id]
video_path = project_path / video_source.file
transcript_path = video_path.with_suffix(".transcript.json")
if not transcript_path.exists():
print(f"Error: Transcript not found: {transcript_path}", file=sys.stderr)
print("Run 'gnommo transcribe' first to generate the transcript.", file=sys.stderr)
print("Run with -a transcribe first.", file=sys.stderr)
return 1
print(f" - Loading transcript: {transcript_path}")
print(f" Loading: {transcript_path.name}")
transcript = load_transcript(transcript_path)
print(f" - Loaded {len(transcript)} words")
print(f" - {len(transcript)} words")
# Align markers
print(" - Aligning markers...")
alignments = align_markers(manuscript_text, transcript, offset_seconds=offset)
# Align
print(" Aligning markers...")
alignments = align_markers(manuscript_text, transcript, offset_seconds=-1.0)
# Report results
print()
print("Alignment results:")
# Report
unmatched = 0
for a in alignments:
if a.timestamp >= 0:
print(f" [{a.marker_id}] @ {a.timestamp:.2f}s - \"{a.matched_phrase}...\"")
if verbose:
print(f" [{a.marker_id}] @ {a.timestamp:.1f}s")
else:
print(f" [{a.marker_id}] NOT FOUND - \"{a.matched_phrase}...\"")
print(f" [{a.marker_id}] NOT FOUND")
unmatched += 1
if unmatched > 0:
print(f"\nWarning: {unmatched} markers could not be aligned")
print(f"\n Warning: {unmatched} markers not aligned")
# Save aligned transcript.csv
# Save
output_path = project_path / "transcript.csv"
save_aligned_transcript(alignments, transcript, output_path)
print(f"\nSaved: {output_path}")
print(f"\n Saved: {output_path}")
return 0
# =============================================================================
# All Command (Full Pipeline)
# =============================================================================
def cmd_all(project_path: Path, verbose: bool, dry_run: bool) -> int:
"""Run full pipeline: transcribe → align → render."""
from .parser import parse_videos
print(f"=== Full Pipeline: {project_path.name} ===\n")
# Check if transcript exists
videos = parse_videos(project_path)
if videos:
video_id = next(iter(videos.keys()))
video_source = videos[video_id]
video_path = project_path / video_source.file
transcript_path = video_path.with_suffix(".transcript.json")
if not transcript_path.exists():
print(">>> Step 1/3: Transcribe\n")
result = cmd_transcribe(project_path, verbose)
if result != 0:
return result
else:
print(f">>> Step 1/3: Transcribe (cached: {transcript_path.name})\n")
# Align
print("\n>>> Step 2/3: Align\n")
result = cmd_align(project_path, verbose)
if result != 0:
return result
# Render
print("\n>>> Step 3/3: Render\n")
return cmd_render(project_path, verbose, dry_run)
if __name__ == "__main__":
sys.exit(main())