22
33from __future__ import annotations
44
5+ import os
6+
57import click
68
79from docgen .config import Config
810
911
12+ def _load_env (cfg : Config | None ) -> None :
13+ """Load .env file from config if specified, so OPENAI_API_KEY etc. are available."""
14+ if cfg and cfg .env_file and cfg .env_file .exists ():
15+ for line in cfg .env_file .read_text ().splitlines ():
16+ line = line .strip ()
17+ if line and not line .startswith ("#" ) and "=" in line :
18+ key , _ , val = line .partition ("=" )
19+ os .environ .setdefault (key .strip (), val .strip ().strip ('"' ).strip ("'" ))
20+
21+
1022@click .group ()
1123@click .option (
1224 "--config" ,
@@ -24,6 +36,7 @@ def main(ctx: click.Context, config_path: str | None) -> None:
2436 except FileNotFoundError :
2537 cfg = None
2638 ctx .obj ["config" ] = cfg
39+ _load_env (cfg )
2740
2841
2942@main .command ()
@@ -74,19 +87,28 @@ def vhs(ctx: click.Context, tape: str | None, strict: bool) -> None:
7487
7588 cfg = ctx .obj ["config" ]
7689 runner = VHSRunner (cfg )
77- runner .render (tape = tape , strict = strict )
90+ results = runner .render (tape = tape , strict = strict )
91+ for r in results :
92+ status = "ok" if r .success else "FAIL"
93+ click .echo (f" [{ status } ] { r .tape } " )
94+ for e in r .errors :
95+ click .echo (f" { e } " )
7896
7997
8098@main .command ()
8199@click .argument ("segments" , nargs = - 1 )
82100@click .pass_context
83101def compose (ctx : click .Context , segments : tuple [str , ...]) -> None :
84- """Compose segments (audio + video)."""
102+ """Compose segments (audio + video via ffmpeg).
103+
104+ Pass segment IDs to compose specific ones, or omit for the default set.
105+ """
85106 from docgen .compose import Composer
86107
87108 cfg = ctx .obj ["config" ]
88109 comp = Composer (cfg )
89110 target = list (segments ) if segments else cfg .segments_default
111+ click .echo (f"=== Composing { len (target )} segments ===" )
90112 comp .compose_segments (target )
91113
92114
@@ -95,7 +117,7 @@ def compose(ctx: click.Context, segments: tuple[str, ...]) -> None:
95117@click .option ("--pre-push" , is_flag = True , help = "Run all checks; exit non-zero on any failure." )
96118@click .pass_context
97119def validate (ctx : click .Context , max_drift : float | None , pre_push : bool ) -> None :
98- """Run validation checks on composed videos."""
120+ """Run validation checks on composed videos (streams, drift, narration lint) ."""
99121 from docgen .validate import Validator
100122
101123 cfg = ctx .obj ["config" ]
@@ -107,11 +129,46 @@ def validate(ctx: click.Context, max_drift: float | None, pre_push: bool) -> Non
107129 v .print_report (report )
108130
109131
132+ @main .command ()
133+ @click .option ("--segment" , default = None , help = "Lint a single segment." )
134+ @click .pass_context
135+ def lint (ctx : click .Context , segment : str | None ) -> None :
136+ """Run narration lint on all (or one) segment narration files."""
137+ from docgen .narration_lint import NarrationLinter
138+
139+ cfg = ctx .obj ["config" ]
140+ linter = NarrationLinter (cfg )
141+ segments = [segment ] if segment else cfg .segments_all
142+ issues_total = 0
143+
144+ for seg_id in segments :
145+ seg_name = cfg .resolve_segment_name (seg_id )
146+ narr_dir = cfg .narration_dir
147+ if not narr_dir .exists ():
148+ continue
149+ path = narr_dir / f"{ seg_name } .md"
150+ if not path .exists ():
151+ candidates = list (narr_dir .glob (f"{ seg_id } -*.md" ))
152+ path = candidates [0 ] if candidates else None
153+ if not path or not path .exists ():
154+ click .echo (f" [{ seg_id } ] no narration file" )
155+ continue
156+ result = linter .lint_text (path .read_text (encoding = "utf-8" ))
157+ status = "PASS" if result .passed else "FAIL"
158+ click .echo (f" [{ seg_id } ] { status } { seg_name } " )
159+ for issue in result .issues :
160+ click .echo (f" { issue } " )
161+ issues_total += 1
162+
163+ if issues_total :
164+ raise SystemExit (1 )
165+
166+
110167@main .command ()
111168@click .option ("--config-name" , "concat_name" , default = None , help = "Concat config name." )
112169@click .pass_context
113170def concat (ctx : click .Context , concat_name : str | None ) -> None :
114- """Concatenate full demo files."""
171+ """Concatenate full demo files from composed segments ."""
115172 from docgen .concat import ConcatBuilder
116173
117174 cfg = ctx .obj ["config" ]
@@ -137,7 +194,7 @@ def pages(ctx: click.Context, force: bool) -> None:
137194@click .option ("--skip-vhs" , is_flag = True )
138195@click .pass_context
139196def generate_all (ctx : click .Context , skip_tts : bool , skip_manim : bool , skip_vhs : bool ) -> None :
140- """Run full pipeline: TTS → Manim → VHS → compose → validate → concat → pages."""
197+ """Run full pipeline: TTS -> Manim -> VHS -> compose -> validate -> concat -> pages."""
141198 from docgen .pipeline import Pipeline
142199
143200 cfg = ctx .obj ["config" ]
@@ -148,9 +205,9 @@ def generate_all(ctx: click.Context, skip_tts: bool, skip_manim: bool, skip_vhs:
148205@main .command ("rebuild-after-audio" )
149206@click .pass_context
150207def rebuild_after_audio (ctx : click .Context ) -> None :
151- """Recompose + validate + concat (skip TTS/ Manim/ VHS) ."""
208+ """Rebuild everything after new audio: Manim -> VHS -> compose -> validate -> concat ."""
152209 from docgen .pipeline import Pipeline
153210
154211 cfg = ctx .obj ["config" ]
155212 pipeline = Pipeline (cfg )
156- pipeline .run (skip_tts = True , skip_manim = True , skip_vhs = True )
213+ pipeline .run (skip_tts = True )
0 commit comments