webmazta commited on
Commit
636ec54
Β·
1 Parent(s): 2f02823

feat(optimizations): reduce frames, add proactive spacing, caching, and project guide

Browse files
Files changed (3) hide show
  1. QWEN.md +233 -0
  2. app.py +33 -3
  3. main.py +79 -19
QWEN.md ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Insta-to-WP (Content Agent) β€” Project Guide
2
+
3
+ ## Overview
4
+
5
+ A Python-based content automation platform that:
6
+ 1. **Social Media β†’ WordPress**: Downloads videos from Instagram/TikTok/YouTube/X, analyzes frames with AI vision models, generates blog posts, and publishes to WordPress
7
+ 2. **Article Rewriter**: Scrapes articles from URLs, rewrites them with AI, finds related media, and publishes to WordPress
8
+ 3. **Web Dashboard**: Flask-based web app with job queue management, RSS feed monitoring, live logs, and a background content publishing agent
9
+
10
+ **Deployment**: Hugging Face Spaces (Docker), Render.com, or local Flask dev server.
11
+
12
+ ---
13
+
14
+ ## Architecture
15
+
16
+ ### Three Main Entry Points
17
+
18
+ | File | Lines | Purpose |
19
+ |---|---|---|
20
+ | `app.py` | ~670 | Flask web dashboard + background agent. Runs on port 5000 (dev) or 7860 (Docker). Web UI at `/` with job submission, queue, live logs, settings, RSS feeds |
21
+ | `main.py` | ~1760 | CLI pipeline for social media video β†’ WordPress. Handles download β†’ frame extraction β†’ AI vision analysis β†’ content generation β†’ featured image creation β†’ WP publish |
22
+ | `article_rewriter.py` | ~1750 | Standalone article pipeline. Handles scrape β†’ AI rewrite β†’ image filtering β†’ related media embedding β†’ featured image β†’ WP publish |
23
+
24
+ ### Dual-Engine AI System
25
+
26
+ - **Primary**: OpenRouter API (`https://openrouter.ai/api/v1`) using `qwen/qwen3.6-plus:free`
27
+ - **Backup**: Groq API (`https://api.groq.com/openai/v1`) using `llama-3.3-70b-versatile` (text) / `llama-3.2-90b-vision-preview` (vision)
28
+ - **Rate Limit Recovery**: All OpenRouter calls wrapped with `_openrouter_with_retry()` β€” on HTTP 429, cools down 90 seconds, retries once, then falls back to Groq
29
+
30
+ ### AI Call Sites (5 total)
31
+
32
+ | Location | Function | Model | Purpose |
33
+ |---|---|---|---|
34
+ | `main.py` | `_analyze_frames()` | Qwen 3.6 Plus (vision) | Analyze up to 60 video frames, return JSON with description, subjects, mood, frame categories (backside/frontside/other) |
35
+ | `main.py` | `generate_content()` | Qwen 3.6 Plus | Generate blog title + 2 paragraphs from video analysis |
36
+ | `article_rewriter.py` | `rewrite_article()` | Qwen 3.6 Plus | Rewrite scraped article into unique blog post with title, paragraphs, SEO description, tags, image alt texts |
37
+ | `article_rewriter.py` | `filter_irrelevant_images()` | Qwen 3.6 Plus | AI-filter article images for relevance (keep content, drop ads/logos/thumbnails) |
38
+ | `app.py` | `screen_article()` | Qwen 3.6 Plus | Screen RSS feed articles against criteria before queuing |
39
+
40
+ ---
41
+
42
+ ## Environment Variables
43
+
44
+ See `.env.template` for all variables. Key ones:
45
+
46
+ ```
47
+ OPENROUTER_API_KEY= # Primary AI API key
48
+ GROQ_API_KEY= # Backup AI API key
49
+ LLM_MODEL=qwen/qwen3.6-plus:free
50
+ GROQ_BACKUP_MODEL=llama-3.3-70b-versatile
51
+ LLM_VISION_BACKUP_MODEL=meta-llama/llama-4-scout-17b-16e-instruct
52
+ GROQ_VISION_MODEL=llama-3.2-90b-vision-preview
53
+ WP_URL= # WordPress site URL
54
+ WP_USERNAME=
55
+ WP_APP_PASSWORD=
56
+ WP_CATEGORY_ID= # Default category for social media posts
57
+ WP_SLAY_CATEGORY_ID= # Category for social media posts
58
+ WP_REWRITE_CATEGORY_ID= # Category for rewritten articles
59
+ WP_POST_STATUS=publish # or "draft"
60
+ IG_USERNAME= # Optional Instagram credentials
61
+ IG_PASSWORD=
62
+ FLASK_SECRET_KEY= # Auto-generated if not set
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Job System
68
+
69
+ ### Database: SQLite (`agent.db`)
70
+
71
+ | Table | Purpose |
72
+ |---|---|
73
+ | `jobs` | Job queue: id, url, title, mode, status (pending/running/done/failed/review), error, result_json, timestamps |
74
+ | `posts` | Published posts tracking |
75
+ | `processed_urls` | URL deduplication tracker |
76
+ | `feeds` | RSS feed subscriptions with last_checked, articles_found |
77
+ | `settings` | Key-value store for agent_enabled, screening_enabled, check_interval_minutes, etc. |
78
+
79
+ ### Job Modes
80
+
81
+ - `"article"` β†’ calls `article_rewriter.process_article()`
82
+ - `"instagram"` / `"social"` β†’ calls `main.process_video()`
83
+ - `"upload"` / `"upload_video"` β†’ calls `main.process_uploaded_video()`
84
+ - `"upload_image"` β†’ calls `main.process_uploaded_image()`
85
+
86
+ ### Processing Flow
87
+
88
+ 1. Jobs created via `POST /api/jobs` or `POST /api/upload` β†’ status `pending`
89
+ 2. Background `agent_loop()` runs every 10s, calls `process_pending_jobs()`
90
+ 3. Jobs processed **sequentially** (one at a time) with `threading.Lock()`
91
+ 4. 90-second sleep between jobs (strict sequential processing)
92
+ 5. Status transitions: `pending` β†’ `running` β†’ `done` | `failed` | `review`
93
+
94
+ ### Background Agent
95
+
96
+ - `agent_loop()` runs continuously, checks `agent_enabled` setting
97
+ - Every `check_interval_minutes` (default 30): checks RSS feeds for new articles
98
+ - RSS articles pass through `screen_article()` AI filter before queuing
99
+ - `review` status jobs appear in dashboard for manual approval before publishing
100
+
101
+ ---
102
+
103
+ ## Deployment
104
+
105
+ ### Docker (Hugging Face Spaces)
106
+
107
+ ```dockerfile
108
+ # Python 3.10-slim base
109
+ # Installs ffmpeg + system libs
110
+ # Runs: gunicorn app:app --bind 0.0.0.0:7860 --timeout 120 --workers 1
111
+ ```
112
+
113
+ ### Render.com
114
+
115
+ Config in `render.yaml` β€” Docker env, Frankfurt region, free plan, health check at `/api/health`
116
+
117
+ ### Local Development
118
+
119
+ ```bash
120
+ # PowerShell (Windows)
121
+ .\start_server.ps1
122
+ # Or directly
123
+ .venv\Scripts\python.exe app.py
124
+ # Visit http://localhost:5000
125
+ ```
126
+
127
+ ### CI/CD
128
+
129
+ GitHub Actions (`.github/workflows/sync_to_hf.yml`) β€” on push to `main`, force-pushes to Hugging Face Spaces.
130
+
131
+ ---
132
+
133
+ ## Frontend
134
+
135
+ | File | Lines | Purpose |
136
+ |---|---|---|
137
+ | `templates/dashboard.html` | ~626 | Main dashboard UI with PWA support |
138
+ | `templates/login.html` | β€” | Login page (credentials: `webmazta` / `2010`) |
139
+ | `static/dashboard.css` | ~837 | "Premium Dark Mode" styling |
140
+ | `static/dashboard.js` | ~775 | Dashboard frontend (job management, live logs, settings) |
141
+ | `static/manifest.json` | β€” | PWA manifest (app name: "Content Agent", theme: #6366f1) |
142
+ | `static/sw.js` | β€” | Service worker for offline support |
143
+
144
+ ---
145
+
146
+ ## Key Technologies
147
+
148
+ - **Video**: `yt-dlp` (download from 1000+ sites), `instaloader` (Instagram), `apify-client` (web scraping)
149
+ - **Vision**: `opencv-python-headless` (frame extraction), `Pillow` (image processing, featured image generation)
150
+ - **AI**: `openai` 1.90.0 (LLM client), `pydantic` (structured output validation)
151
+ - **Web**: `flask` 3.0.0, `gunicorn` 21.2.0, `feedparser` (RSS), `requests`, `aiohttp` (async HTTP)
152
+ - **Scraping**: `beautifulsoup4`, `lxml`
153
+
154
+ ---
155
+
156
+ ## Runtime Directories
157
+
158
+ | Directory | Purpose |
159
+ |---|---|
160
+ | `downloads/` | Downloaded media files (.mp4, .jpg, .txt metadata) |
161
+ | `output/` | Generated featured images (`featured_<id>.jpg`) |
162
+ | `uploads/` | User-uploaded media via dashboard |
163
+
164
+ ---
165
+
166
+ ## Development Guidelines
167
+
168
+ ### Adding New LLM Features
169
+
170
+ 1. Always use the **dual-engine pattern**: OpenRouter primary β†’ Groq backup
171
+ 2. **Wrap all OpenRouter calls** with `_openrouter_with_retry()` β€” 90s cooldown on 429
172
+ 3. Use `response_format={"type": "json_object"}` for structured output
173
+ 4. Include `extra_body={"reasoning": {"max_tokens": N}}` for OpenRouter reasoning tokens
174
+ 5. Log reasoning tokens when available:
175
+ ```python
176
+ usage = getattr(completion, 'usage', None)
177
+ if usage and hasattr(usage, 'extra_fields') and 'reasoning_tokens' in usage.extra_fields:
178
+ print(f" [INFO] Reasoning tokens used: {usage.extra_fields['reasoning_tokens']}")
179
+ ```
180
+
181
+ ### Code Style
182
+
183
+ - Functions use docstrings with clear descriptions
184
+ - Print-based logging with emoji prefixes: `[AI]`, `[ENGINE]`, `[WARN]`, `[RATE LIMIT]`, `[CONTENT]`
185
+ - Network helpers force IPv4 via `socket.getaddrinfo` monkeypatch
186
+ - Configuration loaded from `.env` via `load_dotenv()` at module top
187
+ - Use `os.getenv()` for dynamic config (background worker sync)
188
+
189
+ ### Database Operations
190
+
191
+ - Use `get_db()` helper (returns `sqlite3.Row` dict-like rows)
192
+ - All writes should use parameterized queries (`?` placeholders)
193
+ - Use `LOCK` threading.Lock for job processing serialization
194
+
195
+ ---
196
+
197
+ ## Testing
198
+
199
+ 12 test scripts in root directory:
200
+ - `test_dual_engine.py` β€” OpenRouter + Groq + Vision testing
201
+ - `test_groq_vision.py`, `test_vision_json.py` β€” Vision model tests
202
+ - `test_local_llm.py` β€” LM Studio connectivity
203
+ - `test_screening.py` β€” Article screening
204
+ - Various Gemini model tests (legacy experiments)
205
+
206
+ ---
207
+
208
+ ## Sub-projects (git-ignored)
209
+
210
+ - `insta-to-wp-android/` β€” Android companion app (Kotlin/Gradle)
211
+ - `cobby/` β€” Minimal directory with `.gemini/` subfolder
212
+ - `hf_sync/` β€” Duplicate copy for Hugging Face sync
213
+
214
+ ---
215
+
216
+ ## Quick Commands
217
+
218
+ ```bash
219
+ # Run web dashboard
220
+ python app.py
221
+
222
+ # Process single video
223
+ python main.py <video_url>
224
+
225
+ # Rewrite single article
226
+ python article_rewriter.py <article_url>
227
+
228
+ # Batch process from file
229
+ python main.py --file urls.txt
230
+
231
+ # Stop background processing
232
+ curl -X POST http://localhost:5000/api/jobs/stop
233
+ ```
app.py CHANGED
@@ -47,11 +47,13 @@ def _openrouter_with_retry(api_call_fn, max_retries=1):
47
  """
48
  Retry an OpenRouter API call with 90-second cooldown on rate limit (HTTP 429).
49
  Falls through to caller's exception handling after max retries exhausted.
 
50
  """
51
  import time
52
  for attempt in range(max_retries + 1):
53
  try:
54
- return api_call_fn()
 
55
  except Exception as e:
56
  error_str = str(e)
57
  if "429" in error_str and attempt < max_retries:
@@ -79,6 +81,7 @@ UPLOAD_DIR.mkdir(exist_ok=True)
79
 
80
  LOCK = threading.Lock()
81
  STOP_PROCESSING = threading.Event() # Set this to halt queue processing
 
82
 
83
  # ──────────────────────────────────────────────
84
  # Logging Setup
@@ -296,11 +299,24 @@ def process_pending_jobs():
296
  print(f"[AGENT] ❌ Job #{job_id} failed: {e}")
297
  send_notification("failed", url, str(e)[:200], mode)
298
 
 
 
 
 
299
  return True
300
 
301
  def _process_all_pending():
 
302
  while True:
303
  with LOCK:
 
 
 
 
 
 
 
 
304
  if not process_pending_jobs():
305
  break
306
  time.sleep(90)
@@ -353,7 +369,9 @@ def screen_article(title, summary, criteria):
353
  def _do_screen():
354
  return client.chat.completions.create(model="qwen/qwen3.6-plus:free", messages=[{"role":"user","content":prompt}], response_format={"type":"json_object"})
355
 
356
- resp = _openrouter_with_retry(_do_screen)
 
 
357
  result = json.loads(resp.choices[0].message.content)
358
  print(f"[AGENT] Screening: {result.get('publish')} - {result.get('reason')}")
359
  return result.get("publish", True)
@@ -390,6 +408,7 @@ def check_rss_feeds():
390
  return new_articles
391
 
392
  def agent_loop():
 
393
  print("[AGENT] πŸ€– Agent thread started")
394
  last_rss = 0
395
  while True:
@@ -397,7 +416,13 @@ def agent_loop():
397
  if get_setting("agent_enabled") == "true":
398
  if time.time() - last_rss > int(get_setting("check_interval_minutes", "30")) * 60:
399
  check_rss_feeds(); last_rss = time.time()
400
- with LOCK: process_pending_jobs()
 
 
 
 
 
 
401
  time.sleep(10)
402
  except Exception as e: print(f"[AGENT] Loop error: {e}"); time.sleep(30)
403
 
@@ -659,6 +684,11 @@ def _start_agent():
659
  print("[STARTUP] Initializing database...")
660
  init_db()
661
 
 
 
 
 
 
662
  @app.before_request
663
  def _ensure_agent(): _start_agent()
664
 
 
47
  """
48
  Retry an OpenRouter API call with 90-second cooldown on rate limit (HTTP 429).
49
  Falls through to caller's exception handling after max retries exhausted.
50
+ Returns (response, retries_used) tuple on success.
51
  """
52
  import time
53
  for attempt in range(max_retries + 1):
54
  try:
55
+ result = api_call_fn()
56
+ return result, attempt # Return response + retries used
57
  except Exception as e:
58
  error_str = str(e)
59
  if "429" in error_str and attempt < max_retries:
 
81
 
82
  LOCK = threading.Lock()
83
  STOP_PROCESSING = threading.Event() # Set this to halt queue processing
84
+ LAST_LLM_JOB_TIME = 0 # Timestamp of last LLM-heavy job (for proactive rate-limit spacing)
85
 
86
  # ──────────────────────────────────────────────
87
  # Logging Setup
 
299
  print(f"[AGENT] ❌ Job #{job_id} failed: {e}")
300
  send_notification("failed", url, str(e)[:200], mode)
301
 
302
+ # Track completion time for proactive rate-limit spacing
303
+ global LAST_LLM_JOB_TIME
304
+ LAST_LLM_JOB_TIME = time.time()
305
+
306
  return True
307
 
308
  def _process_all_pending():
309
+ global LAST_LLM_JOB_TIME
310
  while True:
311
  with LOCK:
312
+ # Proactive rate-limit spacing: wait if last LLM job was too recent
313
+ MIN_JOB_GAP = 120 # 2 minutes between LLM-heavy jobs
314
+ elapsed = time.time() - LAST_LLM_JOB_TIME
315
+ if LAST_LLM_JOB_TIME > 0 and elapsed < MIN_JOB_GAP:
316
+ wait = MIN_JOB_GAP - elapsed
317
+ print(f"[AGENT] ⏳ Proactive cooldown: waiting {wait:.0f}s before next job...")
318
+ # Release lock while sleeping to let other threads run
319
+ time.sleep(wait)
320
  if not process_pending_jobs():
321
  break
322
  time.sleep(90)
 
369
  def _do_screen():
370
  return client.chat.completions.create(model="qwen/qwen3.6-plus:free", messages=[{"role":"user","content":prompt}], response_format={"type":"json_object"})
371
 
372
+ resp, retries = _openrouter_with_retry(_do_screen)
373
+ if retries > 0:
374
+ print(f"[AGENT] [RETRY] Screening succeeded after {retries} retry(ies)")
375
  result = json.loads(resp.choices[0].message.content)
376
  print(f"[AGENT] Screening: {result.get('publish')} - {result.get('reason')}")
377
  return result.get("publish", True)
 
408
  return new_articles
409
 
410
  def agent_loop():
411
+ global LAST_LLM_JOB_TIME
412
  print("[AGENT] πŸ€– Agent thread started")
413
  last_rss = 0
414
  while True:
 
416
  if get_setting("agent_enabled") == "true":
417
  if time.time() - last_rss > int(get_setting("check_interval_minutes", "30")) * 60:
418
  check_rss_feeds(); last_rss = time.time()
419
+ with LOCK:
420
+ # Proactive rate-limit spacing in agent loop too
421
+ MIN_JOB_GAP = 120
422
+ elapsed = time.time() - LAST_LLM_JOB_TIME
423
+ if LAST_LLM_JOB_TIME > 0 and elapsed < MIN_JOB_GAP:
424
+ time.sleep(MIN_JOB_GAP - elapsed)
425
+ process_pending_jobs()
426
  time.sleep(10)
427
  except Exception as e: print(f"[AGENT] Loop error: {e}"); time.sleep(30)
428
 
 
684
  print("[STARTUP] Initializing database...")
685
  init_db()
686
 
687
+ # Warn if backup engine has no API key
688
+ if not os.getenv("GROQ_API_KEY"):
689
+ print("[WARN] GROQ_API_KEY is not set. If OpenRouter exhausts retries, jobs will fail with no fallback.")
690
+ print("[WARN] Set GROQ_API_KEY in .env or environment to enable the backup AI engine.")
691
+
692
  @app.before_request
693
  def _ensure_agent(): _start_agent()
694
 
main.py CHANGED
@@ -715,10 +715,12 @@ def _openrouter_with_retry(api_call_fn, max_retries=1):
715
  """
716
  Retry an OpenRouter API call with 90-second cooldown on rate limit (HTTP 429).
717
  Falls through to caller's exception handling after max retries exhausted.
 
718
  """
719
  for attempt in range(max_retries + 1):
720
  try:
721
- return api_call_fn()
 
722
  except Exception as e:
723
  error_str = str(e)
724
  if "429" in error_str and attempt < max_retries:
@@ -738,11 +740,51 @@ def analyze_video(video_path: Path, frames: list[Image.Image] = None) -> dict:
738
  If frames are provided, use them; otherwise extract from video_path.
739
  """
740
  if frames is None:
741
- frames = extract_frames(video_path, count=60)
742
  return _analyze_frames(frames)
743
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
744
  def _analyze_frames(frames: list[Image.Image]) -> dict:
745
  """Analyze provided frames using the dual-engine setup (OpenRouter Primary, Groq Backup)."""
 
 
 
 
 
 
 
746
  print(f" [AI] Analyzing {len(frames)} frames (Vision task)...")
747
 
748
  prompt = textwrap.dedent("""
@@ -781,14 +823,14 @@ def _analyze_frames(frames: list[Image.Image]) -> dict:
781
 
782
  # --- Step 1: Try OpenRouter (Primary) ---
783
  print(f" [DEBUG] OpenAI available: {bool(OpenAI)}, OPENROUTER_API_KEY available: {bool(openrouter_key)}")
784
- print(f" [DEBUG] Model: {LLM_MODEL}, Frames to analyze: {len(frames[:60])}")
785
  if OpenAI and openrouter_key:
786
  try:
787
  print(f" [ENGINE] Trying OpenRouter ({LLM_MODEL})...")
788
  client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=openrouter_key)
789
 
790
  messages_content = [{"type": "text", "text": prompt}]
791
- for frame in frames[:60]:
792
  b64_image = _image_to_base64(frame)
793
  messages_content.append({
794
  "type": "image_url",
@@ -808,8 +850,11 @@ def _analyze_frames(frames: list[Image.Image]) -> dict:
808
  }
809
  )
810
 
811
- completion = _openrouter_with_retry(_do_analyze)
812
  raw_text = completion.choices[0].message.content.strip()
 
 
 
813
 
814
  # Log reasoning tokens if available (OpenRouter specific)
815
  usage = getattr(completion, 'usage', None)
@@ -817,7 +862,13 @@ def _analyze_frames(frames: list[Image.Image]) -> dict:
817
  print(f" [INFO] Reasoning tokens used: {usage.extra_fields['reasoning_tokens']}")
818
 
819
  print(f" [DEBUG] OpenRouter returned {len(raw_text)} chars, starts with: {raw_text[:100]}")
820
- return json.loads(raw_text)
 
 
 
 
 
 
821
  except Exception as e:
822
  error_type = type(e).__name__
823
  error_str = str(e)
@@ -841,7 +892,7 @@ def _analyze_frames(frames: list[Image.Image]) -> dict:
841
  client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=groq_key)
842
 
843
  messages_content = [{"type": "text", "text": prompt}]
844
- for frame in frames[:60]:
845
  b64_image = _image_to_base64(frame)
846
  messages_content.append({
847
  "type": "image_url",
@@ -856,7 +907,13 @@ def _analyze_frames(frames: list[Image.Image]) -> dict:
856
  )
857
  raw_text = completion.choices[0].message.content.strip()
858
  print(f" [DEBUG] Groq returned {len(raw_text)} chars, starts with: {raw_text[:100]}")
859
- return json.loads(raw_text)
 
 
 
 
 
 
860
  except Exception as e:
861
  error_type = type(e).__name__
862
  error_str = str(e)
@@ -935,8 +992,11 @@ def generate_content(analysis: any, source_url: str) -> dict:
935
  }
936
  )
937
 
938
- completion = _openrouter_with_retry(_do_generate)
939
  raw_text = completion.choices[0].message.content.strip()
 
 
 
940
 
941
  # Log reasoning tokens (OpenRouter specific)
942
  usage = getattr(completion, 'usage', None)
@@ -1443,7 +1503,7 @@ def process_video(url_input: any) -> dict:
1443
  is_video = ext in [".mp4", ".webm", ".avi", ".mov"]
1444
 
1445
  # Extract frames
1446
- frames = extract_frames(video_path, count=60 if i == 0 else 5)
1447
  all_frames.extend(frames)
1448
 
1449
  # Upload original to WP
@@ -1459,8 +1519,8 @@ def process_video(url_input: any) -> dict:
1459
  # If grouped, all_frames might have frames from multiple videos.
1460
  # We analyze the primary video's frames specifically or use the global pool.
1461
  # Use the first downloaded video's path as the reference path
1462
- primary_video_path = DOWNLOAD_DIR / f"{safe_name}.mp4"
1463
- analysis = analyze_video(primary_video_path, frames=all_frames[:60])
1464
  categories = analysis.get("frame_categories", {}) if isinstance(analysis, dict) else {}
1465
 
1466
  # Step: Generate Content
@@ -1469,7 +1529,7 @@ def process_video(url_input: any) -> dict:
1469
 
1470
  # Step: Create Collages
1471
  # UNIFIED: Use the same frames that were sent to the AI
1472
- processing_frames = all_frames[:60]
1473
  featured_path = create_featured_image(title, processing_frames, safe_name, categories)
1474
  flyer_path = create_flyer(title, processing_frames, safe_name, categories)
1475
 
@@ -1541,7 +1601,7 @@ def process_uploaded_video(video_path_input: any) -> dict:
1541
  wp_extra_media.append({"id": wp_item["id"], "url": wp_item["url"], "type": "video" if is_video else "image"})
1542
 
1543
  if is_video:
1544
- frames = extract_frames(p, count=60)
1545
  all_frames.extend(frames)
1546
  else:
1547
  try:
@@ -1550,13 +1610,13 @@ def process_uploaded_video(video_path_input: any) -> dict:
1550
  except Exception as e:
1551
  print(f"[WARN] Could not open image {p}: {e}")
1552
 
1553
- # Use first 60 frames for analysis/featured
1554
- frames_for_processing = all_frames[:60]
1555
 
1556
  # Step 2: Analyze primary media with AI
1557
  # UNIFIED: Use the same frames we extracted above
1558
  # frames_for_processing = all_frames[:60]
1559
- analysis = analyze_video(primary_path, frames=all_frames[:60])
1560
  categories = analysis.get("frame_categories", {}) if isinstance(analysis, dict) else {}
1561
 
1562
  # Step 3: Generate blog content
@@ -1568,10 +1628,10 @@ def process_uploaded_video(video_path_input: any) -> dict:
1568
 
1569
  # Step 4: Create featured image
1570
  # UNIFIED: Use the same frames that were sent to the AI
1571
- featured_path = create_featured_image(title, all_frames[:60], safe_name, categories)
1572
 
1573
  # Step 5: Create flyer
1574
- flyer_path = create_flyer(title, all_frames[:60], safe_name, categories)
1575
 
1576
  # Step 6: Upload generated media
1577
  featured_media = upload_media_to_wp(featured_path, f"featured-{safe_name}.jpg")
 
715
  """
716
  Retry an OpenRouter API call with 90-second cooldown on rate limit (HTTP 429).
717
  Falls through to caller's exception handling after max retries exhausted.
718
+ Returns (response, retries_used) tuple on success.
719
  """
720
  for attempt in range(max_retries + 1):
721
  try:
722
+ result = api_call_fn()
723
+ return result, attempt # Return response + retries used
724
  except Exception as e:
725
  error_str = str(e)
726
  if "429" in error_str and attempt < max_retries:
 
740
  If frames are provided, use them; otherwise extract from video_path.
741
  """
742
  if frames is None:
743
+ frames = extract_frames(video_path, count=30)
744
  return _analyze_frames(frames)
745
 
746
+
747
+ # ──────────────────────────────────────────────
748
+ # 2c. Vision Result Cache
749
+ # ──────────────────────────────────────────────
750
+
751
+ _VISION_CACHE_PATH = Path("vision_cache.json")
752
+
753
+ def _get_vision_cache_key(frames: list[Image.Image]) -> str:
754
+ """Create a content hash from frames using first, middle, and last frame fingerprints."""
755
+ hasher = hashlib.md5()
756
+ for idx in [0, len(frames) // 2, -1]:
757
+ frame = frames[idx]
758
+ # Hash a downsampled 64x64 grayscale version for content similarity
759
+ thumb = frame.resize((64, 64)).convert("L").tobytes()
760
+ hasher.update(thumb)
761
+ return hasher.hexdigest()
762
+
763
+ def _load_vision_cache() -> dict:
764
+ if _VISION_CACHE_PATH.exists():
765
+ try:
766
+ with open(_VISION_CACHE_PATH, "r") as f:
767
+ return json.load(f)
768
+ except (json.JSONDecodeError, IOError):
769
+ return {}
770
+ return {}
771
+
772
+ def _save_vision_cache(cache: dict):
773
+ try:
774
+ with open(_VISION_CACHE_PATH, "w") as f:
775
+ json.dump(cache, f)
776
+ except IOError:
777
+ pass
778
+
779
  def _analyze_frames(frames: list[Image.Image]) -> dict:
780
  """Analyze provided frames using the dual-engine setup (OpenRouter Primary, Groq Backup)."""
781
+ # Check cache first
782
+ cache_key = _get_vision_cache_key(frames)
783
+ cache = _load_vision_cache()
784
+ if cache_key in cache:
785
+ print(f" [CACHE] Vision result found for this video content (skip AI call)")
786
+ return cache[cache_key]
787
+
788
  print(f" [AI] Analyzing {len(frames)} frames (Vision task)...")
789
 
790
  prompt = textwrap.dedent("""
 
823
 
824
  # --- Step 1: Try OpenRouter (Primary) ---
825
  print(f" [DEBUG] OpenAI available: {bool(OpenAI)}, OPENROUTER_API_KEY available: {bool(openrouter_key)}")
826
+ print(f" [DEBUG] Model: {LLM_MODEL}, Frames to analyze: {len(frames[:30])}")
827
  if OpenAI and openrouter_key:
828
  try:
829
  print(f" [ENGINE] Trying OpenRouter ({LLM_MODEL})...")
830
  client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=openrouter_key)
831
 
832
  messages_content = [{"type": "text", "text": prompt}]
833
+ for frame in frames[:30]:
834
  b64_image = _image_to_base64(frame)
835
  messages_content.append({
836
  "type": "image_url",
 
850
  }
851
  )
852
 
853
+ completion, retries = _openrouter_with_retry(_do_analyze)
854
  raw_text = completion.choices[0].message.content.strip()
855
+
856
+ if retries > 0:
857
+ print(f" [RETRY] OpenRouter succeeded after {retries} retry(ies)")
858
 
859
  # Log reasoning tokens if available (OpenRouter specific)
860
  usage = getattr(completion, 'usage', None)
 
862
  print(f" [INFO] Reasoning tokens used: {usage.extra_fields['reasoning_tokens']}")
863
 
864
  print(f" [DEBUG] OpenRouter returned {len(raw_text)} chars, starts with: {raw_text[:100]}")
865
+ result = json.loads(raw_text)
866
+ # Save to cache for future reuse
867
+ cache = _load_vision_cache()
868
+ cache[cache_key] = result
869
+ _save_vision_cache(cache)
870
+ print(f" [CACHE] Saved vision result to cache")
871
+ return result
872
  except Exception as e:
873
  error_type = type(e).__name__
874
  error_str = str(e)
 
892
  client = OpenAI(base_url="https://api.groq.com/openai/v1", api_key=groq_key)
893
 
894
  messages_content = [{"type": "text", "text": prompt}]
895
+ for frame in frames[:30]:
896
  b64_image = _image_to_base64(frame)
897
  messages_content.append({
898
  "type": "image_url",
 
907
  )
908
  raw_text = completion.choices[0].message.content.strip()
909
  print(f" [DEBUG] Groq returned {len(raw_text)} chars, starts with: {raw_text[:100]}")
910
+ result = json.loads(raw_text)
911
+ # Save to cache for future reuse
912
+ cache = _load_vision_cache()
913
+ cache[cache_key] = result
914
+ _save_vision_cache(cache)
915
+ print(f" [CACHE] Saved vision result to cache")
916
+ return result
917
  except Exception as e:
918
  error_type = type(e).__name__
919
  error_str = str(e)
 
992
  }
993
  )
994
 
995
+ completion, retries = _openrouter_with_retry(_do_generate)
996
  raw_text = completion.choices[0].message.content.strip()
997
+
998
+ if retries > 0:
999
+ print(f" [RETRY] OpenRouter succeeded after {retries} retry(ies)")
1000
 
1001
  # Log reasoning tokens (OpenRouter specific)
1002
  usage = getattr(completion, 'usage', None)
 
1503
  is_video = ext in [".mp4", ".webm", ".avi", ".mov"]
1504
 
1505
  # Extract frames
1506
+ frames = extract_frames(video_path, count=30 if i == 0 else 5)
1507
  all_frames.extend(frames)
1508
 
1509
  # Upload original to WP
 
1519
  # If grouped, all_frames might have frames from multiple videos.
1520
  # We analyze the primary video's frames specifically or use the global pool.
1521
  # Use the first downloaded video's path as the reference path
1522
+ primary_video_path = DOWNLOAD_DIR / f"{safe_name}.mp4"
1523
+ analysis = analyze_video(primary_video_path, frames=all_frames[:30])
1524
  categories = analysis.get("frame_categories", {}) if isinstance(analysis, dict) else {}
1525
 
1526
  # Step: Generate Content
 
1529
 
1530
  # Step: Create Collages
1531
  # UNIFIED: Use the same frames that were sent to the AI
1532
+ processing_frames = all_frames[:30]
1533
  featured_path = create_featured_image(title, processing_frames, safe_name, categories)
1534
  flyer_path = create_flyer(title, processing_frames, safe_name, categories)
1535
 
 
1601
  wp_extra_media.append({"id": wp_item["id"], "url": wp_item["url"], "type": "video" if is_video else "image"})
1602
 
1603
  if is_video:
1604
+ frames = extract_frames(p, count=30)
1605
  all_frames.extend(frames)
1606
  else:
1607
  try:
 
1610
  except Exception as e:
1611
  print(f"[WARN] Could not open image {p}: {e}")
1612
 
1613
+ # Use first 30 frames for analysis/featured
1614
+ frames_for_processing = all_frames[:30]
1615
 
1616
  # Step 2: Analyze primary media with AI
1617
  # UNIFIED: Use the same frames we extracted above
1618
  # frames_for_processing = all_frames[:60]
1619
+ analysis = analyze_video(primary_path, frames=all_frames[:30])
1620
  categories = analysis.get("frame_categories", {}) if isinstance(analysis, dict) else {}
1621
 
1622
  # Step 3: Generate blog content
 
1628
 
1629
  # Step 4: Create featured image
1630
  # UNIFIED: Use the same frames that were sent to the AI
1631
+ featured_path = create_featured_image(title, all_frames[:30], safe_name, categories)
1632
 
1633
  # Step 5: Create flyer
1634
+ flyer_path = create_flyer(title, all_frames[:30], safe_name, categories)
1635
 
1636
  # Step 6: Upload generated media
1637
  featured_media = upload_media_to_wp(featured_path, f"featured-{safe_name}.jpg")