PlanOpticon

Phase 6: Add batch processing with KG merging and batch summaries Stories 6.1-6.4: Extract reusable process_single_video() pipeline function. Add batch CLI command for folder processing with progress. Knowledge graph merge() with case-insensitive dedup and from_dict() reconstruction. Batch summary generation with aggregated stats and action items.

leo 2026-02-14 22:22 trunk
Commit 09a0b7acefdd54e49ceec31967a84eb032a75783ab408f3dcf345d401bce2a07
--- a/tests/test_batch.py
+++ b/tests/test_batch.py
@@ -0,0 +1,169 @@
1
+"""Tests for batch processing and knowledge graph merging."""
2
+
3
+import json
4
+from pathlib ifest,
5
+ VideoMetadaintegrators.knowledge_graph import KnowledgeGraph
6
+from video_processor.integrators.plan_generator import PlanGenerator
7
+from video_processor.models import (
8
+ ActionItem,
9
+ BatchManifest,
10
+ BatchVideoEntry,
11
+ DiagramResult,
12
+ KeyPoint,
13
+ VideoManifest,
14
+ VideoMetadata,
15
+)
16
+from video_processor.output_structure import (
17
+ create_batch_output_dirs,
18
+ read_batch_manifest,
19
+ write_batch_manifest,
20
+)
21
+
22
+
23
+class TestKnowledgeGraphMerge:
24
+ def test_merge_new_nodes(self):
25
+ kg1 = Knnodes["Python"] = {
26
+ge(kg2)
27
+ "id": "Python",
28
+ "name": "Python",
29
+
30
+ # Should merge into ex"descriptions": {"A programming language"},
31
+ kg2._ "occurrence"""Tests rurrence("Python", nodes["Rust"] = {
32
+ "name": "Rust",
33
+ ""Tests processing and knowledge graph m"descriptions": kg2._ "occurrence"""Tests rccurrences or"Python" in kg1.nodes
34
+ assert "Rust" in kg1.nodes
35
+ assert len(kg1.nodes) == 2
36
+
37
+ def test_merge_overlapping_nodes_case_insensitive(self):
38
+ kg1 = KnowledgeGraph()
39
+ kg1.nodes["Python"] = {
40
+ge(kg2)
41
+ "id": "Python",
42
+ "name": "Python",
43
+
44
+ # Should merge into ex"descriptions": {"Language A"},
45
+ kg2._ "occurrences": [{"source": "v1"}],"""Tests rurrence("Python", nodes["python"] on",
46
+ status="completed",
47
+ diagrams_count=3,
48
+ ),
49
+ BatchVideoEntry(
50
+ video_name="v2",
51
+ manifest_path="videos/v2/manifest.json",
52
+ status="failed",
53
+ error="Audio extraction failed",
54
+ ),
55
+ ],
56
+ )
57
+ write_batch_manifest(manifest, tmp_path)
58
+ restored = read_batch_manifest(tmp_path)
59
+ assert restored.title == "Test Batch"
60
+ assert restored.total_videos == 2
61
+ assert restored.videos[0].status == "completed"
62
+ assert restored.videos[1].error == "Audio extraction failed"
63
+
64
+
65
+class TestBatchSummary:
66
+ def test_generate_batch_summary(self, tmp_path):
67
+ manifests = [
68
+ VideoManifest(
69
+ video=VideoMetadata(title="Meeting 1", duration_seconds=3600),
70
+ key_points=[KeyPoint(point="Point 1")],
71
+ action_items=[ActionItem(action="Do X", assignee="Alice")],
72
+ diagrams=[DiagramResult(frame_index=0, confidence=0.9)],
73
+ ),
74
+ VideoManifest(
75
+ video=VideoMetadata(title="Meeting 2"),
76
+ key_points=[KeyPoint(point="Point 2"), KeyPoint(point="Point 3")],
77
+ action_items=[],
78
+ diagrams=[],
79
+ ),
80
+ ]
81
+
82
+ gen = PlanGenerator()
83
+ summary = gen.generate_batch_summary(
84
+ manifests=manifests,
85
+ title="Weekly Meetings",
86
+ output_path=tmp_path / "summary.md",
87
+ )
88
+
89
+ assert "Weekly Meetings" in summary
90
+ assert "2" in summary # 2 videos
91
+ assert "Meeting 1" in summary
92
+ assert "Meeting 2" in summary
93
+ assert "Do X" in summary
94
+ assert "Alice" in summary
95
+ assert (tmp_path / "summary.md").exists()
96
+
97
+ def test_batch_summary_with_kg(self, tmp_path):
98
+ manifests = [
99
+ VideoManifest(video=VideoMetadata(title="V1")),
100
+ ]
101
+ kg = KnowledgeGraph()
102
+ kg._store.merge_entity("Test", "concept", [])
103
+ kg._store.add_relationship("Test", "Test", "self")
104
+
105
+ gen = PlanGenerator()
106
+ summary = gen.generate_batch_summary(
107
+ manifests=manifests, kg=kg, output_path=tmp_path / "s.md"
108
+ )
109
+ assert "Knowledge Graph" in summary
110
+ assert "mermaid" in summary
111
+"name": "python",
112
+
113
+ # Should merge into ex"descriptions": {"Language B"},
114
+ kg2._ "occurrences": [{"source": "v2"}],"""Tests rccurrences or []:
115
+ kg._sto# Should merge into existing node, not create duplicate
116
+ assert len(kg1.nodes) == 1
117
+ assert "Python" in kg1.nodes
118
+ assert len(kg1.nodes["Python"]["occurrences"]) == 2
119
+ assert "Language B" in kg1.nodes["Python"]["descriptions"]
120
+
121
+ def test_merge_relationships(self):
122
+ kg1 = KnowledgeGraph()
123
+ kg1.relationships = [{"source": "A", "target": "B", "type": "uses"}]urrence("Python", relationships = [{"source": "C", "target": "D", "type": "calls"}]ccurrences or []:
124
+ kg._store.add_occurrence(name, occ.get("source", ""), occ.get("timestamp"), occ.get("text"))
125
+ return kg
126
+
127
+
128
+class TestKnowledgeGraphnodes["X"] = {
129
+s="compl kg2._store.add_occurrenc"name": "X",
130
+ ""Tests processing and knowledge graph m"descriptions": set(),
131
+ kg2._ "occurrencen"]["occurrences"]) == 2
132
+ 2 = KnowledgeGraph()
133
+ kg1.[]:
134
+ kg._store.add_occurrenoncept", ["A programming language"])
135
+ kg1._store.add_occurrence("Python", "video1")
136
+
137
+ nodes["Alice"] = {
138
+ "name": "Alice",
139
+ ""Tests processing anom video_processor.integrators.knowledge_graph import KnowledgeGraph
140
+from video_processor.integrators.plan_generator import PlanGenerator
141
+from video_processor.models import (
142
+ ActionItem,
143
+ BatchManifest,
144
+ BatchVideoEntry,
145
+ DiagramResult,
146
+ KeyPoint,
147
+ VideoManifest,
148
+ VideoMetadata,
149
+)
150
+from video_processor.output_structure import (
151
+ create_batch_output_dirs,
152
+ read_batch_manifest,
153
+ write_batch_manifest,
154
+)
155
+
156
+
157
+def _make_kg_with_entity(name, entity_type="concept", descriptions=None, occurrences=None):
158
+ """Helper to build a KnowledgeGraph with entities via the store API."""
159
+ kg = KnowledgeGraph()
160
+ descs = list(descriptions) if descriptions else []
161
+ kg._store.merge_entity(name, entity_type, descs)
162
+ for occ in occurrences or []:
163
+ kg._store.add_occurrence(name, occ.get("source", ""), occ.get("timestamp"), occ.get("text"))
164
+ return kg
165
+
166
+
167
+class TestKnowledgeGraphMerge:
168
+ def test_merge_new_nodes(self):
169
+ kg1 = Knowledg
--- a/tests/test_batch.py
+++ b/tests/test_batch.py
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_batch.py
+++ b/tests/test_batch.py
@@ -0,0 +1,169 @@
1 """Tests for batch processing and knowledge graph merging."""
2
3 import json
4 from pathlib ifest,
5 VideoMetadaintegrators.knowledge_graph import KnowledgeGraph
6 from video_processor.integrators.plan_generator import PlanGenerator
7 from video_processor.models import (
8 ActionItem,
9 BatchManifest,
10 BatchVideoEntry,
11 DiagramResult,
12 KeyPoint,
13 VideoManifest,
14 VideoMetadata,
15 )
16 from video_processor.output_structure import (
17 create_batch_output_dirs,
18 read_batch_manifest,
19 write_batch_manifest,
20 )
21
22
23 class TestKnowledgeGraphMerge:
24 def test_merge_new_nodes(self):
25 kg1 = Knnodes["Python"] = {
26 ge(kg2)
27 "id": "Python",
28 "name": "Python",
29
30 # Should merge into ex"descriptions": {"A programming language"},
31 kg2._ "occurrence"""Tests rurrence("Python", nodes["Rust"] = {
32 "name": "Rust",
33 ""Tests processing and knowledge graph m"descriptions": kg2._ "occurrence"""Tests rccurrences or"Python" in kg1.nodes
34 assert "Rust" in kg1.nodes
35 assert len(kg1.nodes) == 2
36
37 def test_merge_overlapping_nodes_case_insensitive(self):
38 kg1 = KnowledgeGraph()
39 kg1.nodes["Python"] = {
40 ge(kg2)
41 "id": "Python",
42 "name": "Python",
43
44 # Should merge into ex"descriptions": {"Language A"},
45 kg2._ "occurrences": [{"source": "v1"}],"""Tests rurrence("Python", nodes["python"] on",
46 status="completed",
47 diagrams_count=3,
48 ),
49 BatchVideoEntry(
50 video_name="v2",
51 manifest_path="videos/v2/manifest.json",
52 status="failed",
53 error="Audio extraction failed",
54 ),
55 ],
56 )
57 write_batch_manifest(manifest, tmp_path)
58 restored = read_batch_manifest(tmp_path)
59 assert restored.title == "Test Batch"
60 assert restored.total_videos == 2
61 assert restored.videos[0].status == "completed"
62 assert restored.videos[1].error == "Audio extraction failed"
63
64
65 class TestBatchSummary:
66 def test_generate_batch_summary(self, tmp_path):
67 manifests = [
68 VideoManifest(
69 video=VideoMetadata(title="Meeting 1", duration_seconds=3600),
70 key_points=[KeyPoint(point="Point 1")],
71 action_items=[ActionItem(action="Do X", assignee="Alice")],
72 diagrams=[DiagramResult(frame_index=0, confidence=0.9)],
73 ),
74 VideoManifest(
75 video=VideoMetadata(title="Meeting 2"),
76 key_points=[KeyPoint(point="Point 2"), KeyPoint(point="Point 3")],
77 action_items=[],
78 diagrams=[],
79 ),
80 ]
81
82 gen = PlanGenerator()
83 summary = gen.generate_batch_summary(
84 manifests=manifests,
85 title="Weekly Meetings",
86 output_path=tmp_path / "summary.md",
87 )
88
89 assert "Weekly Meetings" in summary
90 assert "2" in summary # 2 videos
91 assert "Meeting 1" in summary
92 assert "Meeting 2" in summary
93 assert "Do X" in summary
94 assert "Alice" in summary
95 assert (tmp_path / "summary.md").exists()
96
97 def test_batch_summary_with_kg(self, tmp_path):
98 manifests = [
99 VideoManifest(video=VideoMetadata(title="V1")),
100 ]
101 kg = KnowledgeGraph()
102 kg._store.merge_entity("Test", "concept", [])
103 kg._store.add_relationship("Test", "Test", "self")
104
105 gen = PlanGenerator()
106 summary = gen.generate_batch_summary(
107 manifests=manifests, kg=kg, output_path=tmp_path / "s.md"
108 )
109 assert "Knowledge Graph" in summary
110 assert "mermaid" in summary
111 "name": "python",
112
113 # Should merge into ex"descriptions": {"Language B"},
114 kg2._ "occurrences": [{"source": "v2"}],"""Tests rccurrences or []:
115 kg._sto# Should merge into existing node, not create duplicate
116 assert len(kg1.nodes) == 1
117 assert "Python" in kg1.nodes
118 assert len(kg1.nodes["Python"]["occurrences"]) == 2
119 assert "Language B" in kg1.nodes["Python"]["descriptions"]
120
121 def test_merge_relationships(self):
122 kg1 = KnowledgeGraph()
123 kg1.relationships = [{"source": "A", "target": "B", "type": "uses"}]urrence("Python", relationships = [{"source": "C", "target": "D", "type": "calls"}]ccurrences or []:
124 kg._store.add_occurrence(name, occ.get("source", ""), occ.get("timestamp"), occ.get("text"))
125 return kg
126
127
128 class TestKnowledgeGraphnodes["X"] = {
129 s="compl kg2._store.add_occurrenc"name": "X",
130 ""Tests processing and knowledge graph m"descriptions": set(),
131 kg2._ "occurrencen"]["occurrences"]) == 2
132 2 = KnowledgeGraph()
133 kg1.[]:
134 kg._store.add_occurrenoncept", ["A programming language"])
135 kg1._store.add_occurrence("Python", "video1")
136
137 nodes["Alice"] = {
138 "name": "Alice",
139 ""Tests processing anom video_processor.integrators.knowledge_graph import KnowledgeGraph
140 from video_processor.integrators.plan_generator import PlanGenerator
141 from video_processor.models import (
142 ActionItem,
143 BatchManifest,
144 BatchVideoEntry,
145 DiagramResult,
146 KeyPoint,
147 VideoManifest,
148 VideoMetadata,
149 )
150 from video_processor.output_structure import (
151 create_batch_output_dirs,
152 read_batch_manifest,
153 write_batch_manifest,
154 )
155
156
157 def _make_kg_with_entity(name, entity_type="concept", descriptions=None, occurrences=None):
158 """Helper to build a KnowledgeGraph with entities via the store API."""
159 kg = KnowledgeGraph()
160 descs = list(descriptions) if descriptions else []
161 kg._store.merge_entity(name, entity_type, descs)
162 for occ in occurrences or []:
163 kg._store.add_occurrence(name, occ.get("source", ""), occ.get("timestamp"), occ.get("text"))
164 return kg
165
166
167 class TestKnowledgeGraphMerge:
168 def test_merge_new_nodes(self):
169 kg1 = Knowledg
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -1,6 +1,7 @@
11
"""Command-line interface for PlanOpticon."""
2
+
23
import json
34
import logging
45
import os
56
import sys
67
import time
@@ -8,362 +9,254 @@
89
from typing import List, Optional
910
1011
import click
1112
import colorlog
1213
13
-from video_processor.extractors.frame_extractor import extract_frames, save_frames
14
-from video_processor.extractors.audio_extractor import AudioExtractor
15
-from video_processor.api.transcription_api import TranscriptionAPI
16
-from video_processor.api.vision_api import VisionAPI
17
-from video_processor.analyzers.diagram_analyzer import DiagramAnalyzer
18
-from video_processor.integrators.knowledge_graph import KnowledgeGraph
19
-from video_processor.integrators.plan_generator import PlanGenerator
20
-from video_processor.cli.output_formatter import OutputFormatter
21
-
22
-# Configure logging
14
+
2315
def setup_logging(verbose: bool = False) -> None:
2416
"""Set up logging with color formatting."""
2517
log_level = logging.DEBUG if verbose else logging.INFO
26
-
27
- # Create a formatter that includes timestamp, level, and message
2818
formatter = colorlog.ColoredFormatter(
2919
"%(log_color)s%(asctime)s [%(levelname)s] %(message)s",
3020
datefmt="%Y-%m-%d %H:%M:%S",
3121
log_colors={
32
- 'DEBUG': 'cyan',
33
- 'INFO': 'green',
34
- 'WARNING': 'yellow',
35
- 'ERROR': 'red',
36
- 'CRITICAL': 'red,bg_white',
37
- }
38
- )
39
-
40
- # Set up console handler
22
+ "DEBUG": "cyan",
23
+ "INFO": "green",
24
+ "WARNING": "yellow",
25
+ "ERROR": "red",
26
+ "CRITICAL": "red,bg_white",
27
+ },
28
+ )
4129
console_handler = logging.StreamHandler()
4230
console_handler.setFormatter(formatter)
43
-
44
- # Configure root logger
4531
root_logger = logging.getLogger()
4632
root_logger.setLevel(log_level)
47
-
48
- # Remove existing handlers and add our handler
4933
for handler in root_logger.handlers:
5034
root_logger.removeHandler(handler)
5135
root_logger.addHandler(console_handler)
5236
53
-# Main CLI group
37
+
5438
@click.group()
55
-@click.option('--verbose', '-v', is_flag=True, help='Enable verbose output')
56
-@click.version_option('0.1.0', prog_name='PlanOpticon')
39
+@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
40
+@click.version_option("0.2.0", prog_name="PlanOpticon")
5741
@click.pass_context
5842
def cli(ctx, verbose):
5943
"""PlanOpticon - Comprehensive Video Analysis & Knowledge Extraction Tool."""
60
- # Initialize context
61
- ctx.ensure_object(dict)
62
- ctx.obj['verbose'] = verbose
63
-
64
- # Set up logging
65
- setup_logging(verbose)
66
-
67
-@cli.command()
68
-@click.option('--input', '-i', required=True, type=click.Path(exists=True),
69
- help='Input video file path')
70
-@click.option('--output', '-o', required=True, type=click.Path(),
71
- help='Output directory for extracted content')
72
-@click.option('--depth', type=click.Choice(['basic', 'standard', 'comprehensive']),
73
- default='standard', help='Processing depth')
74
-@click.option('--focus', type=str, help='Comma-separated list of focus areas (e.g., "diagrams,action-items")')
75
-@click.option('--use-gpu', is_flag=True, help='Enable GPU acceleration if available')
76
-@click.option('--sampling-rate', type=float, default=0.5,
77
- help='Frame sampling rate (1.0 = every frame)')
78
-@click.option('--change-threshold', type=float, default=0.15,
79
- help='Threshold for detecting visual changes between frames')
80
-@click.option('--title', type=str, help='Title for the analysis report')
81
-@click.option('--provider', '-p', type=click.Choice(['auto', 'openai', 'anthropic', 'gemini']),
82
- default='auto', help='API provider (auto selects best available)')
83
-@click.option('--vision-model', type=str, default=None, help='Override model for vision tasks')
84
-@click.option('--chat-model', type=str, default=None, help='Override model for LLM/chat tasks')
85
-@click.pass_context
86
-def analyze(ctx, input, output, depth, focus, use_gpu, sampling_rate, change_threshold, title,
87
- provider, vision_model, chat_model):
88
- """Analyze video content and extract structured knowledge."""
89
- start_time = time.time()
90
-
91
- # Convert paths
92
- input_path = Path(input)
93
- output_dir = Path(output)
94
- output_dir.mkdir(parents=True, exist_ok=True)
95
-
96
- # Set up cache directory
97
- cache_dir = output_dir / "cache"
98
- cache_dir.mkdir(exist_ok=True)
99
-
100
- # Handle focus areas
101
- focus_areas = []
102
- if focus:
103
- focus_areas = [area.strip().lower() for area in focus.split(',')]
104
-
105
- # Set video title if not provided
106
- if not title:
107
- title = f"Analysis of {input_path.stem}"
108
-
109
- # Log analysis parameters
110
- logging.info(f"Starting analysis of {input_path}")
111
- logging.info(f"Processing depth: {depth}")
112
- if focus_areas:
113
- logging.info(f"Focus areas: {', '.join(focus_areas)}")
114
-
115
- try:
116
- # Create subdirectories
117
- frames_dir = output_dir / "frames"
118
- audio_dir = output_dir / "audio"
119
- transcript_dir = output_dir / "transcript"
120
- diagrams_dir = output_dir / "diagrams"
121
- results_dir = output_dir / "results"
122
-
123
- for directory in [frames_dir, audio_dir, transcript_dir, diagrams_dir, results_dir]:
124
- directory.mkdir(exist_ok=True)
125
-
126
- # Step 1: Extract frames
127
- logging.info("Extracting video frames...")
128
- frames = extract_frames(
129
- input_path,
130
- sampling_rate=sampling_rate,
131
- change_threshold=change_threshold,
132
- disable_gpu=not use_gpu
133
- )
134
- logging.info(f"Extracted {len(frames)} frames")
135
-
136
- # Save frames
137
- frame_paths = save_frames(frames, frames_dir, "frame")
138
- logging.info(f"Saved frames to {frames_dir}")
139
-
140
- # Step 2: Extract audio
141
- logging.info("Extracting audio...")
142
- audio_extractor = AudioExtractor()
143
- audio_path = audio_extractor.extract_audio(
144
- input_path,
145
- output_path=audio_dir / f"{input_path.stem}.wav"
146
- )
147
- audio_props = audio_extractor.get_audio_properties(audio_path)
148
- logging.info(f"Extracted audio: {audio_props['duration']:.2f}s, {audio_props['sample_rate']} Hz")
149
-
150
- # Step 3: Transcribe audio
151
- logging.info("Transcribing audio...")
152
- transcription_api = TranscriptionAPI(
153
- provider="openai", # Could be configurable
154
- cache_dir=cache_dir,
155
- use_cache=True
156
- )
157
-
158
- # Process based on depth
159
- detect_speakers = depth != "basic"
160
- transcription = transcription_api.transcribe_audio(
161
- audio_path,
162
- detect_speakers=detect_speakers,
163
- speakers=2 if detect_speakers else 1 # Default to 2 speakers if detecting
164
- )
165
-
166
- # Save transcript in different formats
167
- transcript_path = transcription_api.save_transcript(
168
- transcription,
169
- transcript_dir / f"{input_path.stem}",
170
- format="json"
171
- )
172
- transcription_api.save_transcript(
173
- transcription,
174
- transcript_dir / f"{input_path.stem}",
175
- format="txt"
176
- )
177
- transcription_api.save_transcript(
178
- transcription,
179
- transcript_dir / f"{input_path.stem}",
180
- format="srt"
181
- )
182
-
183
- logging.info(f"Saved transcripts to {transcript_dir}")
184
-
185
- # Step 4: Diagram extraction and analysis
186
- logging.info("Analyzing visual elements...")
187
-
188
- # Initialize vision API
189
- vision_api = VisionAPI(
190
- provider="openai", # Could be configurable
191
- cache_dir=cache_dir,
192
- use_cache=True
193
- )
194
-
195
- # Initialize diagram analyzer
196
- diagram_analyzer = DiagramAnalyzer(
197
- vision_api=vision_api,
198
- cache_dir=cache_dir,
199
- use_cache=True
200
- )
201
-
202
- # Detect and analyze diagrams
203
- # We pass frame paths instead of numpy arrays for better caching
204
- logging.info("Detecting diagrams in frames...")
205
- diagrams = []
206
-
207
- # Skip diagram detection for basic depth
208
- if depth != "basic" and (not focus_areas or "diagrams" in focus_areas):
209
- # For demo purposes, limit to a subset of frames to reduce API costs
210
- max_frames_to_analyze = 10 if depth == "standard" else 20
211
- frame_subset = frame_paths[:min(max_frames_to_analyze, len(frame_paths))]
212
-
213
- detected_frames = diagram_analyzer.detect_diagrams(frame_subset)
214
-
215
- if detected_frames:
216
- logging.info(f"Detected {len(detected_frames)} potential diagrams")
217
-
218
- # Process each detected diagram
219
- for idx, confidence in detected_frames:
220
- if idx < len(frame_subset):
221
- frame_path = frame_subset[idx]
222
- logging.info(f"Analyzing diagram in frame {idx} (confidence: {confidence:.2f})")
223
-
224
- # Analyze the diagram
225
- analysis = diagram_analyzer.analyze_diagram(frame_path, extract_text=True)
226
-
227
- # Add frame metadata
228
- analysis['frame_index'] = idx
229
- analysis['confidence'] = confidence
230
- analysis['image_path'] = frame_path
231
-
232
- # Generate Mermaid if sufficient analysis available
233
- if depth == "comprehensive" and 'semantic_analysis' in analysis and analysis.get('text_content'):
234
- analysis['mermaid'] = diagram_analyzer.generate_mermaid(analysis)
235
-
236
- # Save diagram image to diagrams directory
237
- import shutil
238
- diagram_path = diagrams_dir / f"diagram_{idx}.jpg"
239
- shutil.copy2(frame_path, diagram_path)
240
- analysis['image_path'] = str(diagram_path)
241
-
242
- # Save analysis as JSON
243
- diagram_json_path = diagrams_dir / f"diagram_{idx}.json"
244
- with open(diagram_json_path, 'w') as f:
245
- json.dump(analysis, f, indent=2)
246
-
247
- diagrams.append(analysis)
248
- else:
249
- logging.info("No diagrams detected in analyzed frames")
250
-
251
- # Step 5: Generate knowledge graph and markdown report
252
- logging.info("Generating knowledge graph and report...")
253
-
254
- # Initialize knowledge graph
255
- knowledge_graph = KnowledgeGraph(
256
- cache_dir=cache_dir,
257
- use_cache=True
258
- )
259
-
260
- # Initialize plan generator
261
- plan_generator = PlanGenerator(
262
- knowledge_graph=knowledge_graph,
263
- cache_dir=cache_dir,
264
- use_cache=True
265
- )
266
-
267
- # Process transcript and diagrams
268
- with open(transcript_path) as f:
269
- transcript_data = json.load(f)
270
-
271
- # Process into knowledge graph
272
- knowledge_graph.process_transcript(transcript_data)
273
- if diagrams:
274
- knowledge_graph.process_diagrams(diagrams)
275
-
276
- # Save knowledge graph
277
- kg_path = knowledge_graph.save(results_dir / "knowledge_graph.json")
278
-
279
- # Extract key points
280
- key_points = plan_generator.extract_key_points(transcript_data)
281
-
282
- # Generate markdown
283
- with open(kg_path) as f:
284
- kg_data = json.load(f)
285
-
286
- markdown_path = results_dir / "analysis.md"
287
- markdown_content = plan_generator.generate_markdown(
288
- transcript=transcript_data,
289
- key_points=key_points,
290
- diagrams=diagrams,
291
- knowledge_graph=kg_data,
292
- video_title=title,
293
- output_path=markdown_path
294
- )
295
-
296
- # Format and organize outputs
297
- output_formatter = OutputFormatter(output_dir)
298
- outputs = output_formatter.organize_outputs(
299
- markdown_path=markdown_path,
300
- knowledge_graph_path=kg_path,
301
- diagrams=diagrams,
302
- frames_dir=frames_dir,
303
- transcript_path=transcript_path
304
- )
305
-
306
- # Create HTML index
307
- index_path = output_formatter.create_html_index(outputs)
308
-
309
- # Finalize
310
- elapsed = time.time() - start_time
311
- logging.info(f"Analysis completed in {elapsed:.2f} seconds")
312
- logging.info(f"Results available at {index_path}")
313
-
314
- except Exception as e:
315
- logging.error(f"Error during analysis: {str(e)}")
316
- if ctx.obj['verbose']:
317
- import traceback
318
- traceback.print_exc()
319
- sys.exit(1)
320
-
321
-@cli.command()
322
-@click.option('--cache-dir', type=click.Path(), help='Path to cache directory')
323
-@click.option('--older-than', type=int, help='Clear entries older than N seconds')
324
-@click.option('--all', 'clear_all', is_flag=True, help='Clear all cache entries')
325
-@click.pass_context
326
-def clear_cache(ctx, cache_dir, older_than, clear_all):
327
- """Clear API response cache."""
328
- if not cache_dir and not os.environ.get('CACHE_DIR'):
329
- logging.error("Cache directory not specified")
330
- sys.exit(1)
331
-
332
- cache_path = Path(cache_dir or os.environ.get('CACHE_DIR'))
333
-
334
- if not cache_path.exists():
335
- logging.warning(f"Cache directory does not exist: {cache_path}")
336
- return
337
-
338
- try:
339
- # Clear specific caches
340
- from video_processor.utils.api_cache import ApiCache
341
-
342
- namespaces = [d.name for d in cache_path.iterdir() if d.is_dir()]
343
-
344
- if not namespaces:
345
- logging.info("No cache namespaces found")
346
- return
347
-
348
- total_cleared = 0
349
- for namespace in namespaces:
350
- cache = ApiCache(cache_path, namespace)
351
- cleared = cache.clear(older_than if not clear_all else None)
352
- total_cleared += cleared
353
- logging.info(f"Cleared {cleared} entries from {namespace} cache")
354
-
355
- logging.info(f"Total cleared: {total_cleared} entries")
356
-
357
- except Exception as e:
358
- logging.error(f"Error clearing cache: {str(e)}")
359
- if ctx.obj['verbose']:
360
- import traceback
361
- traceback.print_exc()
362
- sys.exit(1)
363
-
364
-@cli.command('list-models')
44
+ ctx.ensure_object(dict)
45
+ ctx.obj["verbose"] = verbose
46
+ setup_logging(verbose)
47
+
48
+
49
+@cli.command()
50
+@click.option("--input", "-i", required=True, type=click.Path(exists=True), help="Input video file path")
51
+@click.option("--output", "-o", required=True, type=click.Path(), help="Output directory")
52
+@click.option(
53
+ "--depth",
54
+ type=click.Choice(["basic", "standard", "comprehensive"]),
55
+ default="standard",
56
+ help="Processing depth",
57
+)
58
+@click.option("--focus", type=str, help='Comma-separated focus areas (e.g., "diagrams,action-items")')
59
+@click.option("--use-gpu", is_flag=True, help="Enable GPU acceleration if available")
60
+@click.option("--sampling-rate", type=float, default=0.5, help="Frame sampling rate")
61
+@click.option("--change-threshold", type=float, default=0.15, help="Visual change threshold")
62
+@click.option("--title", type=str, help="Title for the analysis report")
63
+@click.option(
64
+ "--provider",
65
+ "-p",
66
+ type=click.Choice(["auto", "openai", "anthropic", "gemini"]),
67
+ default="auto",
68
+ help="API provider",
69
+)
70
+@click.option("--vision-model", type=str, default=None, help="Override model for vision tasks")
71
+@click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
72
+@click.pass_context
73
+def analyze(
74
+ ctx,
75
+ input,
76
+ output,
77
+ depth,
78
+ focus,
79
+ use_gpu,
80
+ sampling_rate,
81
+ change_threshold,
82
+ title,
83
+ provider,
84
+ vision_model,
85
+ chat_model,
86
+):
87
+ """Analyze a single video and extract structured knowledge."""
88
+ from video_processor.pipeline import process_single_video
89
+ from video_processor.providers.manager import ProviderManager
90
+
91
+ focus_areas = [a.strip().lower() for a in focus.split(",")] if focus else []
92
+ prov = None if provider == "auto" else provider
93
+
94
+ pm = ProviderManager(
95
+ vision_model=vision_model,
96
+ chat_model=chat_model,
97
+ provider=prov,
98
+ )
99
+
100
+ try:
101
+ manifest = process_single_video(
102
+ input_path=input,
103
+ output_dir=output,
104
+ provider_manager=pm,
105
+ depth=depth,
106
+ focus_areas=focus_areas,
107
+ sampling_rate=sampling_rate,
108
+ change_threshold=change_threshold,
109
+ use_gpu=use_gpu,
110
+ title=title,
111
+ )
112
+ logging.info(f"Results at {output}/manifest.json")
113
+ except Exception as e:
114
+ logging.error(f"Error: {e}")
115
+ if ctx.obj["verbose"]:
116
+ import traceback
117
+
118
+ traceback.print_exc()
119
+ sys.exit(1)
120
+
121
+
122
+@cli.command()
123
+@click.option("--input-dir", "-i", required=True, type=click.Path(exists=True), help="Directory of videos")
124
+@click.option("--output", "-o", required=True, type=click.Path(), help="Output directory")
125
+@click.option(
126
+ "--depth",
127
+ type=click.Choice(["basic", "standard", "comprehensive"]),
128
+ default="standard",
129
+ help="Processing depth",
130
+)
131
+@click.option(
132
+ "--pattern",
133
+ type=str,
134
+ default="*.mp4,*.mkv,*.avi,*.mov,*.webm",
135
+ help="File glob patterns (comma-separated)",
136
+)
137
+@click.option("--title", type=str, default="Batch Processing Results", help="Batch title")
138
+@click.option(
139
+ "--provider",
140
+ "-p",
141
+ type=click.Choice(["auto", "openai", "anthropic", "gemini"]),
142
+ default="auto",
143
+ help="API provider",
144
+)
145
+@click.option("--vision-model", type=str, default=None, help="Override model for vision tasks")
146
+@click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
147
+@click.pass_context
148
+def batch(ctx, input_dir, output, depth, pattern, title, provider, vision_model, chat_model):
149
+ """Process a folder of videos in batch."""
150
+ from video_processor.integrators.knowledge_graph import KnowledgeGraph
151
+ from video_processor.integrators.plan_generator import PlanGenerator
152
+ from video_processor.models import BatchManifest, BatchVideoEntry
153
+ from video_processor.output_structure import (
154
+ create_batch_output_dirs,
155
+ read_video_manifest,
156
+ write_batch_manifest,
157
+ )
158
+ from video_processor.pipeline import process_single_video
159
+ from video_processor.providers.manager import ProviderManager
160
+
161
+ input_dir = Path(input_dir)
162
+ prov = None if provider == "auto" else provider
163
+ pm = ProviderManager(vision_model=vision_model, chat_model=chat_model, provider=prov)
164
+
165
+ # Find videos
166
+ patterns = [p.strip() for p in pattern.split(",")]
167
+ videos = []
168
+ for pat in patterns:
169
+ videos.extend(sorted(input_dir.glob(pat)))
170
+ videos = sorted(set(videos))
171
+
172
+ if not videos:
173
+ logging.error(f"No videos found in {input_dir} matching {pattern}")
174
+ sys.exit(1)
175
+
176
+ logging.info(f"Found {len(videos)} videos to process")
177
+
178
+ dirs = create_batch_output_dirs(output, title)
179
+ manifests = []
180
+ entries = []
181
+ merged_kg = KnowledgeGraph()
182
+
183
+ for idx, video_path in enumerate(videos):
184
+ video_name = video_path.stem
185
+ video_output = dirs["videos"] / video_name
186
+ logging.info(f"Processing video {idx + 1}/{len(videos)}: {video_path.name}")
187
+
188
+ entry = BatchVideoEntry(
189
+ video_name=video_name,
190
+ manifest_path=f"videos/{video_name}/manifest.json",
191
+ )
192
+
193
+ try:
194
+ manifest = process_single_video(
195
+ input_path=video_path,
196
+ output_dir=video_output,
197
+ provider_manager=pm,
198
+ depth=depth,
199
+ title=f"Analysis of {video_name}",
200
+ )
201
+ entry.status = "completed"
202
+ entry.diagrams_count = len(manifest.diagrams)
203
+ entry.action_items_count = len(manifest.action_items)
204
+ entry.key_points_count = len(manifest.key_points)
205
+ entry.duration_seconds = manifest.video.duration_seconds
206
+ manifests.append(manifest)
207
+
208
+ # Merge knowledge graph
209
+ kg_path = video_output / "results" / "knowledge_graph.json"
210
+ if kg_path.exists():
211
+ kg_data = json.loads(kg_path.read_text())
212
+ video_kg = KnowledgeGraph.from_dict(kg_data)
213
+ merged_kg.merge(video_kg)
214
+
215
+ except Exception as e:
216
+ logging.error(f"Failed to process {video_path.name}: {e}")
217
+ entry.status = "failed"
218
+ entry.error = str(e)
219
+ if ctx.obj["verbose"]:
220
+ import traceback
221
+
222
+ traceback.print_exc()
223
+
224
+ entries.append(entry)
225
+
226
+ # Save merged knowledge graph
227
+ merged_kg_path = Path(output) / "knowledge_graph.json"
228
+ merged_kg.save(merged_kg_path)
229
+
230
+ # Generate batch summary
231
+ plan_gen = PlanGenerator(provider_manager=pm, knowledge_graph=merged_kg)
232
+ summary_path = Path(output) / "batch_summary.md"
233
+ plan_gen.generate_batch_summary(
234
+ manifests=manifests,
235
+ kg=merged_kg,
236
+ title=title,
237
+ output_path=summary_path,
238
+ )
239
+
240
+ # Write batch manifest
241
+ batch_manifest = BatchManifest(
242
+ title=title,
243
+ total_videos=len(videos),
244
+ completed_videos=sum(1 for e in entries if e.status == "completed"),
245
+ failed_videos=sum(1 for e in entries if e.status == "failed"),
246
+ total_diagrams=sum(e.diagrams_count for e in entries),
247
+ total_action_items=sum(e.action_items_count for e in entries),
248
+ total_key_points=sum(e.key_points_count for e in entries),
249
+ videos=entries,
250
+ batch_summary_md="batch_summary.md",
251
+ merged_knowledge_graph_json="knowledge_graph.json",
252
+ )
253
+ write_batch_manifest(batch_manifest, output)
254
+ logging.info(f"Batch complete: {batch_manifest.completed_videos}/{batch_manifest.total_videos} succeeded")
255
+
256
+
257
+@cli.command("list-models")
365258
@click.pass_context
366259
def list_models(ctx):
367260
"""Discover and display available models from all configured providers."""
368261
from video_processor.providers.discovery import discover_available_models
369262
@@ -371,11 +264,10 @@
371264
if not models:
372265
click.echo("No models discovered. Check that at least one API key is set:")
373266
click.echo(" OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY")
374267
return
375268
376
- # Group by provider
377269
by_provider: dict[str, list] = {}
378270
for m in models:
379271
by_provider.setdefault(m.provider, []).append(m)
380272
381273
for provider, provider_models in sorted(by_provider.items()):
@@ -385,12 +277,54 @@
385277
caps = ", ".join(m.capabilities)
386278
click.echo(f" {m.id:<40} [{caps}]")
387279
388280
click.echo(f"\nTotal: {len(models)} models across {len(by_provider)} providers")
389281
282
+
283
+@cli.command()
284
+@click.option("--cache-dir", type=click.Path(), help="Path to cache directory")
285
+@click.option("--older-than", type=int, help="Clear entries older than N seconds")
286
+@click.option("--all", "clear_all", is_flag=True, help="Clear all cache entries")
287
+@click.pass_context
288
+def clear_cache(ctx, cache_dir, older_than, clear_all):
289
+ """Clear API response cache."""
290
+ if not cache_dir and not os.environ.get("CACHE_DIR"):
291
+ logging.error("Cache directory not specified")
292
+ sys.exit(1)
293
+
294
+ cache_path = Path(cache_dir or os.environ.get("CACHE_DIR"))
295
+ if not cache_path.exists():
296
+ logging.warning(f"Cache directory does not exist: {cache_path}")
297
+ return
298
+
299
+ try:
300
+ from video_processor.utils.api_cache import ApiCache
301
+
302
+ namespaces = [d.name for d in cache_path.iterdir() if d.is_dir()]
303
+ if not namespaces:
304
+ logging.info("No cache namespaces found")
305
+ return
306
+
307
+ total_cleared = 0
308
+ for namespace in namespaces:
309
+ cache = ApiCache(cache_path, namespace)
310
+ cleared = cache.clear(older_than if not clear_all else None)
311
+ total_cleared += cleared
312
+ logging.info(f"Cleared {cleared} entries from {namespace} cache")
313
+
314
+ logging.info(f"Total cleared: {total_cleared} entries")
315
+ except Exception as e:
316
+ logging.error(f"Error clearing cache: {e}")
317
+ if ctx.obj["verbose"]:
318
+ import traceback
319
+
320
+ traceback.print_exc()
321
+ sys.exit(1)
322
+
390323
391324
def main():
392325
"""Entry point for command-line usage."""
393326
cli(obj={})
394327
395
-if __name__ == '__main__':
328
+
329
+if __name__ == "__main__":
396330
main()
397331
398332
ADDED video_processor/integrators/plan_generator.py
399333
ADDED video_processor/pipeline.py
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -1,6 +1,7 @@
1 """Command-line interface for PlanOpticon."""
 
2 import json
3 import logging
4 import os
5 import sys
6 import time
@@ -8,362 +9,254 @@
8 from typing import List, Optional
9
10 import click
11 import colorlog
12
13 from video_processor.extractors.frame_extractor import extract_frames, save_frames
14 from video_processor.extractors.audio_extractor import AudioExtractor
15 from video_processor.api.transcription_api import TranscriptionAPI
16 from video_processor.api.vision_api import VisionAPI
17 from video_processor.analyzers.diagram_analyzer import DiagramAnalyzer
18 from video_processor.integrators.knowledge_graph import KnowledgeGraph
19 from video_processor.integrators.plan_generator import PlanGenerator
20 from video_processor.cli.output_formatter import OutputFormatter
21
22 # Configure logging
23 def setup_logging(verbose: bool = False) -> None:
24 """Set up logging with color formatting."""
25 log_level = logging.DEBUG if verbose else logging.INFO
26
27 # Create a formatter that includes timestamp, level, and message
28 formatter = colorlog.ColoredFormatter(
29 "%(log_color)s%(asctime)s [%(levelname)s] %(message)s",
30 datefmt="%Y-%m-%d %H:%M:%S",
31 log_colors={
32 'DEBUG': 'cyan',
33 'INFO': 'green',
34 'WARNING': 'yellow',
35 'ERROR': 'red',
36 'CRITICAL': 'red,bg_white',
37 }
38 )
39
40 # Set up console handler
41 console_handler = logging.StreamHandler()
42 console_handler.setFormatter(formatter)
43
44 # Configure root logger
45 root_logger = logging.getLogger()
46 root_logger.setLevel(log_level)
47
48 # Remove existing handlers and add our handler
49 for handler in root_logger.handlers:
50 root_logger.removeHandler(handler)
51 root_logger.addHandler(console_handler)
52
53 # Main CLI group
54 @click.group()
55 @click.option('--verbose', '-v', is_flag=True, help='Enable verbose output')
56 @click.version_option('0.1.0', prog_name='PlanOpticon')
57 @click.pass_context
58 def cli(ctx, verbose):
59 """PlanOpticon - Comprehensive Video Analysis & Knowledge Extraction Tool."""
60 # Initialize context
61 ctx.ensure_object(dict)
62 ctx.obj['verbose'] = verbose
63
64 # Set up logging
65 setup_logging(verbose)
66
67 @cli.command()
68 @click.option('--input', '-i', required=True, type=click.Path(exists=True),
69 help='Input video file path')
70 @click.option('--output', '-o', required=True, type=click.Path(),
71 help='Output directory for extracted content')
72 @click.option('--depth', type=click.Choice(['basic', 'standard', 'comprehensive']),
73 default='standard', help='Processing depth')
74 @click.option('--focus', type=str, help='Comma-separated list of focus areas (e.g., "diagrams,action-items")')
75 @click.option('--use-gpu', is_flag=True, help='Enable GPU acceleration if available')
76 @click.option('--sampling-rate', type=float, default=0.5,
77 help='Frame sampling rate (1.0 = every frame)')
78 @click.option('--change-threshold', type=float, default=0.15,
79 help='Threshold for detecting visual changes between frames')
80 @click.option('--title', type=str, help='Title for the analysis report')
81 @click.option('--provider', '-p', type=click.Choice(['auto', 'openai', 'anthropic', 'gemini']),
82 default='auto', help='API provider (auto selects best available)')
83 @click.option('--vision-model', type=str, default=None, help='Override model for vision tasks')
84 @click.option('--chat-model', type=str, default=None, help='Override model for LLM/chat tasks')
85 @click.pass_context
86 def analyze(ctx, input, output, depth, focus, use_gpu, sampling_rate, change_threshold, title,
87 provider, vision_model, chat_model):
88 """Analyze video content and extract structured knowledge."""
89 start_time = time.time()
90
91 # Convert paths
92 input_path = Path(input)
93 output_dir = Path(output)
94 output_dir.mkdir(parents=True, exist_ok=True)
95
96 # Set up cache directory
97 cache_dir = output_dir / "cache"
98 cache_dir.mkdir(exist_ok=True)
99
100 # Handle focus areas
101 focus_areas = []
102 if focus:
103 focus_areas = [area.strip().lower() for area in focus.split(',')]
104
105 # Set video title if not provided
106 if not title:
107 title = f"Analysis of {input_path.stem}"
108
109 # Log analysis parameters
110 logging.info(f"Starting analysis of {input_path}")
111 logging.info(f"Processing depth: {depth}")
112 if focus_areas:
113 logging.info(f"Focus areas: {', '.join(focus_areas)}")
114
115 try:
116 # Create subdirectories
117 frames_dir = output_dir / "frames"
118 audio_dir = output_dir / "audio"
119 transcript_dir = output_dir / "transcript"
120 diagrams_dir = output_dir / "diagrams"
121 results_dir = output_dir / "results"
122
123 for directory in [frames_dir, audio_dir, transcript_dir, diagrams_dir, results_dir]:
124 directory.mkdir(exist_ok=True)
125
126 # Step 1: Extract frames
127 logging.info("Extracting video frames...")
128 frames = extract_frames(
129 input_path,
130 sampling_rate=sampling_rate,
131 change_threshold=change_threshold,
132 disable_gpu=not use_gpu
133 )
134 logging.info(f"Extracted {len(frames)} frames")
135
136 # Save frames
137 frame_paths = save_frames(frames, frames_dir, "frame")
138 logging.info(f"Saved frames to {frames_dir}")
139
140 # Step 2: Extract audio
141 logging.info("Extracting audio...")
142 audio_extractor = AudioExtractor()
143 audio_path = audio_extractor.extract_audio(
144 input_path,
145 output_path=audio_dir / f"{input_path.stem}.wav"
146 )
147 audio_props = audio_extractor.get_audio_properties(audio_path)
148 logging.info(f"Extracted audio: {audio_props['duration']:.2f}s, {audio_props['sample_rate']} Hz")
149
150 # Step 3: Transcribe audio
151 logging.info("Transcribing audio...")
152 transcription_api = TranscriptionAPI(
153 provider="openai", # Could be configurable
154 cache_dir=cache_dir,
155 use_cache=True
156 )
157
158 # Process based on depth
159 detect_speakers = depth != "basic"
160 transcription = transcription_api.transcribe_audio(
161 audio_path,
162 detect_speakers=detect_speakers,
163 speakers=2 if detect_speakers else 1 # Default to 2 speakers if detecting
164 )
165
166 # Save transcript in different formats
167 transcript_path = transcription_api.save_transcript(
168 transcription,
169 transcript_dir / f"{input_path.stem}",
170 format="json"
171 )
172 transcription_api.save_transcript(
173 transcription,
174 transcript_dir / f"{input_path.stem}",
175 format="txt"
176 )
177 transcription_api.save_transcript(
178 transcription,
179 transcript_dir / f"{input_path.stem}",
180 format="srt"
181 )
182
183 logging.info(f"Saved transcripts to {transcript_dir}")
184
185 # Step 4: Diagram extraction and analysis
186 logging.info("Analyzing visual elements...")
187
188 # Initialize vision API
189 vision_api = VisionAPI(
190 provider="openai", # Could be configurable
191 cache_dir=cache_dir,
192 use_cache=True
193 )
194
195 # Initialize diagram analyzer
196 diagram_analyzer = DiagramAnalyzer(
197 vision_api=vision_api,
198 cache_dir=cache_dir,
199 use_cache=True
200 )
201
202 # Detect and analyze diagrams
203 # We pass frame paths instead of numpy arrays for better caching
204 logging.info("Detecting diagrams in frames...")
205 diagrams = []
206
207 # Skip diagram detection for basic depth
208 if depth != "basic" and (not focus_areas or "diagrams" in focus_areas):
209 # For demo purposes, limit to a subset of frames to reduce API costs
210 max_frames_to_analyze = 10 if depth == "standard" else 20
211 frame_subset = frame_paths[:min(max_frames_to_analyze, len(frame_paths))]
212
213 detected_frames = diagram_analyzer.detect_diagrams(frame_subset)
214
215 if detected_frames:
216 logging.info(f"Detected {len(detected_frames)} potential diagrams")
217
218 # Process each detected diagram
219 for idx, confidence in detected_frames:
220 if idx < len(frame_subset):
221 frame_path = frame_subset[idx]
222 logging.info(f"Analyzing diagram in frame {idx} (confidence: {confidence:.2f})")
223
224 # Analyze the diagram
225 analysis = diagram_analyzer.analyze_diagram(frame_path, extract_text=True)
226
227 # Add frame metadata
228 analysis['frame_index'] = idx
229 analysis['confidence'] = confidence
230 analysis['image_path'] = frame_path
231
232 # Generate Mermaid if sufficient analysis available
233 if depth == "comprehensive" and 'semantic_analysis' in analysis and analysis.get('text_content'):
234 analysis['mermaid'] = diagram_analyzer.generate_mermaid(analysis)
235
236 # Save diagram image to diagrams directory
237 import shutil
238 diagram_path = diagrams_dir / f"diagram_{idx}.jpg"
239 shutil.copy2(frame_path, diagram_path)
240 analysis['image_path'] = str(diagram_path)
241
242 # Save analysis as JSON
243 diagram_json_path = diagrams_dir / f"diagram_{idx}.json"
244 with open(diagram_json_path, 'w') as f:
245 json.dump(analysis, f, indent=2)
246
247 diagrams.append(analysis)
248 else:
249 logging.info("No diagrams detected in analyzed frames")
250
251 # Step 5: Generate knowledge graph and markdown report
252 logging.info("Generating knowledge graph and report...")
253
254 # Initialize knowledge graph
255 knowledge_graph = KnowledgeGraph(
256 cache_dir=cache_dir,
257 use_cache=True
258 )
259
260 # Initialize plan generator
261 plan_generator = PlanGenerator(
262 knowledge_graph=knowledge_graph,
263 cache_dir=cache_dir,
264 use_cache=True
265 )
266
267 # Process transcript and diagrams
268 with open(transcript_path) as f:
269 transcript_data = json.load(f)
270
271 # Process into knowledge graph
272 knowledge_graph.process_transcript(transcript_data)
273 if diagrams:
274 knowledge_graph.process_diagrams(diagrams)
275
276 # Save knowledge graph
277 kg_path = knowledge_graph.save(results_dir / "knowledge_graph.json")
278
279 # Extract key points
280 key_points = plan_generator.extract_key_points(transcript_data)
281
282 # Generate markdown
283 with open(kg_path) as f:
284 kg_data = json.load(f)
285
286 markdown_path = results_dir / "analysis.md"
287 markdown_content = plan_generator.generate_markdown(
288 transcript=transcript_data,
289 key_points=key_points,
290 diagrams=diagrams,
291 knowledge_graph=kg_data,
292 video_title=title,
293 output_path=markdown_path
294 )
295
296 # Format and organize outputs
297 output_formatter = OutputFormatter(output_dir)
298 outputs = output_formatter.organize_outputs(
299 markdown_path=markdown_path,
300 knowledge_graph_path=kg_path,
301 diagrams=diagrams,
302 frames_dir=frames_dir,
303 transcript_path=transcript_path
304 )
305
306 # Create HTML index
307 index_path = output_formatter.create_html_index(outputs)
308
309 # Finalize
310 elapsed = time.time() - start_time
311 logging.info(f"Analysis completed in {elapsed:.2f} seconds")
312 logging.info(f"Results available at {index_path}")
313
314 except Exception as e:
315 logging.error(f"Error during analysis: {str(e)}")
316 if ctx.obj['verbose']:
317 import traceback
318 traceback.print_exc()
319 sys.exit(1)
320
321 @cli.command()
322 @click.option('--cache-dir', type=click.Path(), help='Path to cache directory')
323 @click.option('--older-than', type=int, help='Clear entries older than N seconds')
324 @click.option('--all', 'clear_all', is_flag=True, help='Clear all cache entries')
325 @click.pass_context
326 def clear_cache(ctx, cache_dir, older_than, clear_all):
327 """Clear API response cache."""
328 if not cache_dir and not os.environ.get('CACHE_DIR'):
329 logging.error("Cache directory not specified")
330 sys.exit(1)
331
332 cache_path = Path(cache_dir or os.environ.get('CACHE_DIR'))
333
334 if not cache_path.exists():
335 logging.warning(f"Cache directory does not exist: {cache_path}")
336 return
337
338 try:
339 # Clear specific caches
340 from video_processor.utils.api_cache import ApiCache
341
342 namespaces = [d.name for d in cache_path.iterdir() if d.is_dir()]
343
344 if not namespaces:
345 logging.info("No cache namespaces found")
346 return
347
348 total_cleared = 0
349 for namespace in namespaces:
350 cache = ApiCache(cache_path, namespace)
351 cleared = cache.clear(older_than if not clear_all else None)
352 total_cleared += cleared
353 logging.info(f"Cleared {cleared} entries from {namespace} cache")
354
355 logging.info(f"Total cleared: {total_cleared} entries")
356
357 except Exception as e:
358 logging.error(f"Error clearing cache: {str(e)}")
359 if ctx.obj['verbose']:
360 import traceback
361 traceback.print_exc()
362 sys.exit(1)
363
364 @cli.command('list-models')
365 @click.pass_context
366 def list_models(ctx):
367 """Discover and display available models from all configured providers."""
368 from video_processor.providers.discovery import discover_available_models
369
@@ -371,11 +264,10 @@
371 if not models:
372 click.echo("No models discovered. Check that at least one API key is set:")
373 click.echo(" OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY")
374 return
375
376 # Group by provider
377 by_provider: dict[str, list] = {}
378 for m in models:
379 by_provider.setdefault(m.provider, []).append(m)
380
381 for provider, provider_models in sorted(by_provider.items()):
@@ -385,12 +277,54 @@
385 caps = ", ".join(m.capabilities)
386 click.echo(f" {m.id:<40} [{caps}]")
387
388 click.echo(f"\nTotal: {len(models)} models across {len(by_provider)} providers")
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
391 def main():
392 """Entry point for command-line usage."""
393 cli(obj={})
394
395 if __name__ == '__main__':
 
396 main()
397
398 DDED video_processor/integrators/plan_generator.py
399 DDED video_processor/pipeline.py
--- video_processor/cli/commands.py
+++ video_processor/cli/commands.py
@@ -1,6 +1,7 @@
1 """Command-line interface for PlanOpticon."""
2
3 import json
4 import logging
5 import os
6 import sys
7 import time
@@ -8,362 +9,254 @@
9 from typing import List, Optional
10
11 import click
12 import colorlog
13
14
 
 
 
 
 
 
 
 
 
15 def setup_logging(verbose: bool = False) -> None:
16 """Set up logging with color formatting."""
17 log_level = logging.DEBUG if verbose else logging.INFO
 
 
18 formatter = colorlog.ColoredFormatter(
19 "%(log_color)s%(asctime)s [%(levelname)s] %(message)s",
20 datefmt="%Y-%m-%d %H:%M:%S",
21 log_colors={
22 "DEBUG": "cyan",
23 "INFO": "green",
24 "WARNING": "yellow",
25 "ERROR": "red",
26 "CRITICAL": "red,bg_white",
27 },
28 )
 
 
29 console_handler = logging.StreamHandler()
30 console_handler.setFormatter(formatter)
 
 
31 root_logger = logging.getLogger()
32 root_logger.setLevel(log_level)
 
 
33 for handler in root_logger.handlers:
34 root_logger.removeHandler(handler)
35 root_logger.addHandler(console_handler)
36
37
38 @click.group()
39 @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
40 @click.version_option("0.2.0", prog_name="PlanOpticon")
41 @click.pass_context
42 def cli(ctx, verbose):
43 """PlanOpticon - Comprehensive Video Analysis & Knowledge Extraction Tool."""
44 ctx.ensure_object(dict)
45 ctx.obj["verbose"] = verbose
46 setup_logging(verbose)
47
48
49 @cli.command()
50 @click.option("--input", "-i", required=True, type=click.Path(exists=True), help="Input video file path")
51 @click.option("--output", "-o", required=True, type=click.Path(), help="Output directory")
52 @click.option(
53 "--depth",
54 type=click.Choice(["basic", "standard", "comprehensive"]),
55 default="standard",
56 help="Processing depth",
57 )
58 @click.option("--focus", type=str, help='Comma-separated focus areas (e.g., "diagrams,action-items")')
59 @click.option("--use-gpu", is_flag=True, help="Enable GPU acceleration if available")
60 @click.option("--sampling-rate", type=float, default=0.5, help="Frame sampling rate")
61 @click.option("--change-threshold", type=float, default=0.15, help="Visual change threshold")
62 @click.option("--title", type=str, help="Title for the analysis report")
63 @click.option(
64 "--provider",
65 "-p",
66 type=click.Choice(["auto", "openai", "anthropic", "gemini"]),
67 default="auto",
68 help="API provider",
69 )
70 @click.option("--vision-model", type=str, default=None, help="Override model for vision tasks")
71 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
72 @click.pass_context
73 def analyze(
74 ctx,
75 input,
76 output,
77 depth,
78 focus,
79 use_gpu,
80 sampling_rate,
81 change_threshold,
82 title,
83 provider,
84 vision_model,
85 chat_model,
86 ):
87 """Analyze a single video and extract structured knowledge."""
88 from video_processor.pipeline import process_single_video
89 from video_processor.providers.manager import ProviderManager
90
91 focus_areas = [a.strip().lower() for a in focus.split(",")] if focus else []
92 prov = None if provider == "auto" else provider
93
94 pm = ProviderManager(
95 vision_model=vision_model,
96 chat_model=chat_model,
97 provider=prov,
98 )
99
100 try:
101 manifest = process_single_video(
102 input_path=input,
103 output_dir=output,
104 provider_manager=pm,
105 depth=depth,
106 focus_areas=focus_areas,
107 sampling_rate=sampling_rate,
108 change_threshold=change_threshold,
109 use_gpu=use_gpu,
110 title=title,
111 )
112 logging.info(f"Results at {output}/manifest.json")
113 except Exception as e:
114 logging.error(f"Error: {e}")
115 if ctx.obj["verbose"]:
116 import traceback
117
118 traceback.print_exc()
119 sys.exit(1)
120
121
122 @cli.command()
123 @click.option("--input-dir", "-i", required=True, type=click.Path(exists=True), help="Directory of videos")
124 @click.option("--output", "-o", required=True, type=click.Path(), help="Output directory")
125 @click.option(
126 "--depth",
127 type=click.Choice(["basic", "standard", "comprehensive"]),
128 default="standard",
129 help="Processing depth",
130 )
131 @click.option(
132 "--pattern",
133 type=str,
134 default="*.mp4,*.mkv,*.avi,*.mov,*.webm",
135 help="File glob patterns (comma-separated)",
136 )
137 @click.option("--title", type=str, default="Batch Processing Results", help="Batch title")
138 @click.option(
139 "--provider",
140 "-p",
141 type=click.Choice(["auto", "openai", "anthropic", "gemini"]),
142 default="auto",
143 help="API provider",
144 )
145 @click.option("--vision-model", type=str, default=None, help="Override model for vision tasks")
146 @click.option("--chat-model", type=str, default=None, help="Override model for LLM/chat tasks")
147 @click.pass_context
148 def batch(ctx, input_dir, output, depth, pattern, title, provider, vision_model, chat_model):
149 """Process a folder of videos in batch."""
150 from video_processor.integrators.knowledge_graph import KnowledgeGraph
151 from video_processor.integrators.plan_generator import PlanGenerator
152 from video_processor.models import BatchManifest, BatchVideoEntry
153 from video_processor.output_structure import (
154 create_batch_output_dirs,
155 read_video_manifest,
156 write_batch_manifest,
157 )
158 from video_processor.pipeline import process_single_video
159 from video_processor.providers.manager import ProviderManager
160
161 input_dir = Path(input_dir)
162 prov = None if provider == "auto" else provider
163 pm = ProviderManager(vision_model=vision_model, chat_model=chat_model, provider=prov)
164
165 # Find videos
166 patterns = [p.strip() for p in pattern.split(",")]
167 videos = []
168 for pat in patterns:
169 videos.extend(sorted(input_dir.glob(pat)))
170 videos = sorted(set(videos))
171
172 if not videos:
173 logging.error(f"No videos found in {input_dir} matching {pattern}")
174 sys.exit(1)
175
176 logging.info(f"Found {len(videos)} videos to process")
177
178 dirs = create_batch_output_dirs(output, title)
179 manifests = []
180 entries = []
181 merged_kg = KnowledgeGraph()
182
183 for idx, video_path in enumerate(videos):
184 video_name = video_path.stem
185 video_output = dirs["videos"] / video_name
186 logging.info(f"Processing video {idx + 1}/{len(videos)}: {video_path.name}")
187
188 entry = BatchVideoEntry(
189 video_name=video_name,
190 manifest_path=f"videos/{video_name}/manifest.json",
191 )
192
193 try:
194 manifest = process_single_video(
195 input_path=video_path,
196 output_dir=video_output,
197 provider_manager=pm,
198 depth=depth,
199 title=f"Analysis of {video_name}",
200 )
201 entry.status = "completed"
202 entry.diagrams_count = len(manifest.diagrams)
203 entry.action_items_count = len(manifest.action_items)
204 entry.key_points_count = len(manifest.key_points)
205 entry.duration_seconds = manifest.video.duration_seconds
206 manifests.append(manifest)
207
208 # Merge knowledge graph
209 kg_path = video_output / "results" / "knowledge_graph.json"
210 if kg_path.exists():
211 kg_data = json.loads(kg_path.read_text())
212 video_kg = KnowledgeGraph.from_dict(kg_data)
213 merged_kg.merge(video_kg)
214
215 except Exception as e:
216 logging.error(f"Failed to process {video_path.name}: {e}")
217 entry.status = "failed"
218 entry.error = str(e)
219 if ctx.obj["verbose"]:
220 import traceback
221
222 traceback.print_exc()
223
224 entries.append(entry)
225
226 # Save merged knowledge graph
227 merged_kg_path = Path(output) / "knowledge_graph.json"
228 merged_kg.save(merged_kg_path)
229
230 # Generate batch summary
231 plan_gen = PlanGenerator(provider_manager=pm, knowledge_graph=merged_kg)
232 summary_path = Path(output) / "batch_summary.md"
233 plan_gen.generate_batch_summary(
234 manifests=manifests,
235 kg=merged_kg,
236 title=title,
237 output_path=summary_path,
238 )
239
240 # Write batch manifest
241 batch_manifest = BatchManifest(
242 title=title,
243 total_videos=len(videos),
244 completed_videos=sum(1 for e in entries if e.status == "completed"),
245 failed_videos=sum(1 for e in entries if e.status == "failed"),
246 total_diagrams=sum(e.diagrams_count for e in entries),
247 total_action_items=sum(e.action_items_count for e in entries),
248 total_key_points=sum(e.key_points_count for e in entries),
249 videos=entries,
250 batch_summary_md="batch_summary.md",
251 merged_knowledge_graph_json="knowledge_graph.json",
252 )
253 write_batch_manifest(batch_manifest, output)
254 logging.info(f"Batch complete: {batch_manifest.completed_videos}/{batch_manifest.total_videos} succeeded")
255
256
257 @cli.command("list-models")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258 @click.pass_context
259 def list_models(ctx):
260 """Discover and display available models from all configured providers."""
261 from video_processor.providers.discovery import discover_available_models
262
@@ -371,11 +264,10 @@
264 if not models:
265 click.echo("No models discovered. Check that at least one API key is set:")
266 click.echo(" OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY")
267 return
268
 
269 by_provider: dict[str, list] = {}
270 for m in models:
271 by_provider.setdefault(m.provider, []).append(m)
272
273 for provider, provider_models in sorted(by_provider.items()):
@@ -385,12 +277,54 @@
277 caps = ", ".join(m.capabilities)
278 click.echo(f" {m.id:<40} [{caps}]")
279
280 click.echo(f"\nTotal: {len(models)} models across {len(by_provider)} providers")
281
282
283 @cli.command()
284 @click.option("--cache-dir", type=click.Path(), help="Path to cache directory")
285 @click.option("--older-than", type=int, help="Clear entries older than N seconds")
286 @click.option("--all", "clear_all", is_flag=True, help="Clear all cache entries")
287 @click.pass_context
288 def clear_cache(ctx, cache_dir, older_than, clear_all):
289 """Clear API response cache."""
290 if not cache_dir and not os.environ.get("CACHE_DIR"):
291 logging.error("Cache directory not specified")
292 sys.exit(1)
293
294 cache_path = Path(cache_dir or os.environ.get("CACHE_DIR"))
295 if not cache_path.exists():
296 logging.warning(f"Cache directory does not exist: {cache_path}")
297 return
298
299 try:
300 from video_processor.utils.api_cache import ApiCache
301
302 namespaces = [d.name for d in cache_path.iterdir() if d.is_dir()]
303 if not namespaces:
304 logging.info("No cache namespaces found")
305 return
306
307 total_cleared = 0
308 for namespace in namespaces:
309 cache = ApiCache(cache_path, namespace)
310 cleared = cache.clear(older_than if not clear_all else None)
311 total_cleared += cleared
312 logging.info(f"Cleared {cleared} entries from {namespace} cache")
313
314 logging.info(f"Total cleared: {total_cleared} entries")
315 except Exception as e:
316 logging.error(f"Error clearing cache: {e}")
317 if ctx.obj["verbose"]:
318 import traceback
319
320 traceback.print_exc()
321 sys.exit(1)
322
323
324 def main():
325 """Entry point for command-line usage."""
326 cli(obj={})
327
328
329 if __name__ == "__main__":
330 main()
331
332 DDED video_processor/integrators/plan_generator.py
333 DDED video_processor/pipeline.py
--- a/video_processor/integrators/plan_generator.py
+++ b/video_processor/integrators/plan_generator.py
@@ -0,0 +1,135 @@
1
+"""Plan generation for creating structured markdown output."""
2
+
3
+import json"""Plan generation for creating structured markdown output."""
4
+
5
+import logging
6
+from pathlib import Path
7
+from typing import Dict, List, Optional, Union
8
+
9
+from video_processor.integrators.knowledge_"""Plan generaf"{segment.get('speaker', 'Speaker')}: " if "speaker" in segment else ""
10
+ full_text += f"{speaker}{segment['text']}\n\n"
11
+
12
+ if not full_text.strip():
13
+ full_text = transcript.get("text", "")
14
+
15
+ return self._chat(
16
+ f"Provide a concise 3-5 paragraph summary of this transcript:\n\n{full_text[:6000]}",
17
+ max_tokens=800,
18
+ )
19
+
20
+ def generate_markdown(
21
+ self,
22
+ transcript: Dict,
23
+ key_points: List[Dict],
24
+ diagrams: List[Dict],
25
+ knowledge_graph: Dict,
26
+ video_title: Optional[str] = None,
27
+ output_path: Optional[Union[str, Path]] = None,
28
+ ) -> str:
29
+ """Generate markdown report content."""
30
+ summary = self.generate_summary(transcript)
31
+ title = video_title or "Video Analysis Report"
32
+
33
+ md = [f"# {title}", "", "## Summary", "", summary, "", "## Key Points", ""]
34
+
35
+ for point in key_points:
36
+ p = point.get("point", "") if isinstance(point, dict) else str(point)
37
+ md.append(f"- **{p}**")
38
+ details = point.get("details") if isinstance(point, dict) else None
39
+ if details:
40
+ if isinstance(details, list):
41
+ for d in details:
42
+ md.append(f" - {d}")
43
+ else:
44
+ md.append(f" {details}")
45
+ md.append("")
46
+
47
+ if diagrams:
48
+ md.append("## Visual Elements")
49
+ md.append("")
50
+ for i, diagram in enumerate(diagrams):
51
+ md.append(f"### Diagram {i + 1}")
52
+ md.append("")
53
+ desc = diagram.get("description", "")
54
+ if desc:
55
+ md.append(desc)
56
+ md.append("")
57
+ if diagram.get("image_path"):
58
+ md.append(f"![Diagram {i + 1}]({diagram['image_path']})")
59
+ md.append("")
60
+ if diagram.get("mermaid"):
61
+ md.append("```mermaid")
62
+ md.append(diagram["mermaid"])
63
+ md.append("```")
64
+ md.append("")
65
+
66
+ if knowledge_graph and knowledge_graph.get("nodes"):
67
+ md.append("## Knowledge Graph")
68
+ md.append("")
69
+ kg = KnowledgeGraph.from_dict(knowledge_graph)
70
+ mermaid_code = kg.generate_mermaid(max_nodes=25)
71
+ md.append("```mermaid")
72
+ md.append(mermaid_code)
73
+ md.append("```")
74
+ md.append("")
75
+
76
+ markdown_content = "\n".join(md)
77
+
78
+ if output_path:
79
+ output_path = Path(output_path)
80
+ if not output_path.suffix:
81
+ output_path = output_path.with_suffix(".md")
82
+ output_path.parent.mkdir(parents=True, exist_ok=True)
83
+ output_path.write_text(markdown_content)
84
+ logger.info(f"Saved markdown to {output_path}")
85
+
86
+ return markdown_content
87
+
88
+ def generate_batch_summary(
89
+ self,
90
+ manifests: List[VideoManifest],
91
+ kg: Optional[KnowledgeGraph] = None,
92
+ title: str = "Batch Processing Summary",
93
+ output_path: Optional[Union[str, Path]] = None,
94
+ ) -> str:
95
+ """Generate a batch summary across multiple videos."""
96
+ md = [f"# {title}", ""]
97
+
98
+ # Overview stats
99
+ total_diagrams = sum(len(m.diagrams) for m in manifests)
100
+ total_kp = sum(len(m.key_points) for m in manifests)
101
+ total_ai = sum(len(m.action_items) for m in manifests)
102
+
103
+ md.append("## Overview")
104
+ md.append("")
105
+ md.append(f"- **Videos processed:** {len(manifests)}")
106
+ md.append(f"- **Total diagrams:** {total_diagrams}")
107
+ md.append(f"- **Total key points:** {total_kp}")
108
+ md.append(f"- **Total action items:** {total_ai}")
109
+ md.append("")
110
+
111
+ # Per-video summaries
112
+ md.append("## Per-Video Summaries")
113
+ md.append("")
114
+ for m in manifests:
115
+ md.append(f"### {m.video.title}")
116
+ md.append("")
117
+ md.append(f"- Diagrams: {len(m.diagrams)}")
118
+ md.append(f"- Key points: {len(m.key_points)}")
119
+ md.append(f"- Action items: {len(m.action_items)}")
120
+ if m.video.duration_seconds:
121
+ md.append(f"- Duration: {m.video.duration_seconds:.0f}s")
122
+ md.append("")
123
+
124
+ # Aggregated action items
125
+ if total_ai > 0:
126
+ md.append("## All Action Items")
127
+ md.append("")
128
+ for m in manifests:
129
+ for ai in m.action_items:
130
+ line = f"- **{ai.action}**"
131
+ if ai.assignee:
132
+ line += f" ({ai.assignee})"
133
+ if ai.deadline:
134
+ line += f" — {ai.deadline}"
135
+ line += f" _{m.video.title}_"
--- a/video_processor/integrators/plan_generator.py
+++ b/video_processor/integrators/plan_generator.py
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/video_processor/integrators/plan_generator.py
+++ b/video_processor/integrators/plan_generator.py
@@ -0,0 +1,135 @@
1 """Plan generation for creating structured markdown output."""
2
3 import json"""Plan generation for creating structured markdown output."""
4
5 import logging
6 from pathlib import Path
7 from typing import Dict, List, Optional, Union
8
9 from video_processor.integrators.knowledge_"""Plan generaf"{segment.get('speaker', 'Speaker')}: " if "speaker" in segment else ""
10 full_text += f"{speaker}{segment['text']}\n\n"
11
12 if not full_text.strip():
13 full_text = transcript.get("text", "")
14
15 return self._chat(
16 f"Provide a concise 3-5 paragraph summary of this transcript:\n\n{full_text[:6000]}",
17 max_tokens=800,
18 )
19
20 def generate_markdown(
21 self,
22 transcript: Dict,
23 key_points: List[Dict],
24 diagrams: List[Dict],
25 knowledge_graph: Dict,
26 video_title: Optional[str] = None,
27 output_path: Optional[Union[str, Path]] = None,
28 ) -> str:
29 """Generate markdown report content."""
30 summary = self.generate_summary(transcript)
31 title = video_title or "Video Analysis Report"
32
33 md = [f"# {title}", "", "## Summary", "", summary, "", "## Key Points", ""]
34
35 for point in key_points:
36 p = point.get("point", "") if isinstance(point, dict) else str(point)
37 md.append(f"- **{p}**")
38 details = point.get("details") if isinstance(point, dict) else None
39 if details:
40 if isinstance(details, list):
41 for d in details:
42 md.append(f" - {d}")
43 else:
44 md.append(f" {details}")
45 md.append("")
46
47 if diagrams:
48 md.append("## Visual Elements")
49 md.append("")
50 for i, diagram in enumerate(diagrams):
51 md.append(f"### Diagram {i + 1}")
52 md.append("")
53 desc = diagram.get("description", "")
54 if desc:
55 md.append(desc)
56 md.append("")
57 if diagram.get("image_path"):
58 md.append(f"![Diagram {i + 1}]({diagram['image_path']})")
59 md.append("")
60 if diagram.get("mermaid"):
61 md.append("```mermaid")
62 md.append(diagram["mermaid"])
63 md.append("```")
64 md.append("")
65
66 if knowledge_graph and knowledge_graph.get("nodes"):
67 md.append("## Knowledge Graph")
68 md.append("")
69 kg = KnowledgeGraph.from_dict(knowledge_graph)
70 mermaid_code = kg.generate_mermaid(max_nodes=25)
71 md.append("```mermaid")
72 md.append(mermaid_code)
73 md.append("```")
74 md.append("")
75
76 markdown_content = "\n".join(md)
77
78 if output_path:
79 output_path = Path(output_path)
80 if not output_path.suffix:
81 output_path = output_path.with_suffix(".md")
82 output_path.parent.mkdir(parents=True, exist_ok=True)
83 output_path.write_text(markdown_content)
84 logger.info(f"Saved markdown to {output_path}")
85
86 return markdown_content
87
88 def generate_batch_summary(
89 self,
90 manifests: List[VideoManifest],
91 kg: Optional[KnowledgeGraph] = None,
92 title: str = "Batch Processing Summary",
93 output_path: Optional[Union[str, Path]] = None,
94 ) -> str:
95 """Generate a batch summary across multiple videos."""
96 md = [f"# {title}", ""]
97
98 # Overview stats
99 total_diagrams = sum(len(m.diagrams) for m in manifests)
100 total_kp = sum(len(m.key_points) for m in manifests)
101 total_ai = sum(len(m.action_items) for m in manifests)
102
103 md.append("## Overview")
104 md.append("")
105 md.append(f"- **Videos processed:** {len(manifests)}")
106 md.append(f"- **Total diagrams:** {total_diagrams}")
107 md.append(f"- **Total key points:** {total_kp}")
108 md.append(f"- **Total action items:** {total_ai}")
109 md.append("")
110
111 # Per-video summaries
112 md.append("## Per-Video Summaries")
113 md.append("")
114 for m in manifests:
115 md.append(f"### {m.video.title}")
116 md.append("")
117 md.append(f"- Diagrams: {len(m.diagrams)}")
118 md.append(f"- Key points: {len(m.key_points)}")
119 md.append(f"- Action items: {len(m.action_items)}")
120 if m.video.duration_seconds:
121 md.append(f"- Duration: {m.video.duration_seconds:.0f}s")
122 md.append("")
123
124 # Aggregated action items
125 if total_ai > 0:
126 md.append("## All Action Items")
127 md.append("")
128 for m in manifests:
129 for ai in m.action_items:
130 line = f"- **{ai.action}**"
131 if ai.assignee:
132 line += f" ({ai.assignee})"
133 if ai.deadline:
134 line += f" — {ai.deadline}"
135 line += f" _{m.video.title}_"
--- a/video_processor/pipeline.py
+++ b/video_processor/pipeline.py
@@ -0,0 +1,57 @@
1
+"""Core video processing pipeline — the reusable function both CLI commands call."""
2
+
3
+import """
4
+
5
+import hashlib
6
+import time
7
+from datetime import datetime
8
+from pathlib import Path
9
+from typing import Optional
10
+
11
+from rom tqdm import FrameAudioI commands call."""
12
+
13
+import hashlib
14
+import json
15
+import logging
16
+import mimetypes
17
+import time
18
+from datetime import datetime
19
+from pathlib import Path
20
+from typing importextract_frames,,save_framesort json
21
+import loggin json.loads(kg_json_pathkg = Knowlif kg_json"""Core video processing pipeline — the reusable function both CLI commands call."""
22
+
23
+import """
24
+
25
+import hashlib
26
+import time
27
+from daBif
28
+
29
+ ] = [
30
+tetime import datetime
31
+f" ]"""Core vie — the reusable input_path,
32
+)
33
+frames"audio...")
34
+ output_path="""Core video processieo processing "duration"providerprovider"),
35
+}
36
+json"
37
+# SRT
38
+srt_lines = []
39
+)
40
+srt_lines.append("")
41
+subset = frame_paths[:min(maxif kg_path = kg.save(""Core video procevideo processing pipeline — t# Save structured data
42
+ kpkey_points.json"
43
+ai_path[aimdnalysis.md"
44
+ime
45
+from pathlib import Path
46
+from typing import Optional
47
+
48
+from tqdm import FrameAudioI commands call."""
49
+
50
+import hashlib
51
+import json
52
+import logging
53
+import mimetypes
54
+import time
55
+from datetime import datetime
56
+from pathlib import Path
57
+from typing importextract_frames,, save_frames"""Core video proccompleteif kg_json_data = json.loads(kg_json_pathkg = Knowlif kg_json"""Core video processing pipeli"logger.info("Export
--- a/video_processor/pipeline.py
+++ b/video_processor/pipeline.py
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/video_processor/pipeline.py
+++ b/video_processor/pipeline.py
@@ -0,0 +1,57 @@
1 """Core video processing pipeline — the reusable function both CLI commands call."""
2
3 import """
4
5 import hashlib
6 import time
7 from datetime import datetime
8 from pathlib import Path
9 from typing import Optional
10
11 from rom tqdm import FrameAudioI commands call."""
12
13 import hashlib
14 import json
15 import logging
16 import mimetypes
17 import time
18 from datetime import datetime
19 from pathlib import Path
20 from typing importextract_frames,,save_framesort json
21 import loggin json.loads(kg_json_pathkg = Knowlif kg_json"""Core video processing pipeline — the reusable function both CLI commands call."""
22
23 import """
24
25 import hashlib
26 import time
27 from daBif
28
29 ] = [
30 tetime import datetime
31 f" ]"""Core vie — the reusable input_path,
32 )
33 frames"audio...")
34 output_path="""Core video processieo processing "duration"providerprovider"),
35 }
36 json"
37 # SRT
38 srt_lines = []
39 )
40 srt_lines.append("")
41 subset = frame_paths[:min(maxif kg_path = kg.save(""Core video procevideo processing pipeline — t# Save structured data
42 kpkey_points.json"
43 ai_path[aimdnalysis.md"
44 ime
45 from pathlib import Path
46 from typing import Optional
47
48 from tqdm import FrameAudioI commands call."""
49
50 import hashlib
51 import json
52 import logging
53 import mimetypes
54 import time
55 from datetime import datetime
56 from pathlib import Path
57 from typing importextract_frames,, save_frames"""Core video proccompleteif kg_json_data = json.loads(kg_json_pathkg = Knowlif kg_json"""Core video processing pipeli"logger.info("Export

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button