synop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synop/__init__.py +1 -0
- synop/ai_client.py +214 -0
- synop/cli.py +506 -0
- synop/formatter.py +99 -0
- synop/settings.py +81 -0
- synop/templates/example.j2 +14 -0
- synop/thumbnailer.py +468 -0
- synop/video.py +229 -0
- synop-0.1.0.dist-info/METADATA +13 -0
- synop-0.1.0.dist-info/RECORD +13 -0
- synop-0.1.0.dist-info/WHEEL +4 -0
- synop-0.1.0.dist-info/entry_points.txt +2 -0
- synop-0.1.0.dist-info/licenses/LICENSE +21 -0
synop/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
synop/ai_client.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
12
|
+
DEFAULT_MODEL = "x-ai/grok-4.20-beta"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class DescriptionResult:
|
|
17
|
+
title: str
|
|
18
|
+
short_summary: str
|
|
19
|
+
generated_description: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenRouterError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenRouterClient:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: str,
|
|
30
|
+
model: str = DEFAULT_MODEL,
|
|
31
|
+
timeout: float = 120.0,
|
|
32
|
+
api_base_url: str = OPENROUTER_BASE_URL,
|
|
33
|
+
):
|
|
34
|
+
self.api_key = api_key
|
|
35
|
+
self.model = model
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
self.api_base_url = api_base_url.rstrip("/")
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def chat_completions_url(self) -> str:
|
|
41
|
+
if self.api_base_url.lower().endswith("/chat/completions"):
|
|
42
|
+
return self.api_base_url
|
|
43
|
+
return urljoin(f"{self.api_base_url}/", "chat/completions")
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def is_openrouter(self) -> bool:
|
|
47
|
+
return "openrouter.ai" in self.api_base_url.lower()
|
|
48
|
+
|
|
49
|
+
async def generate_description(self, frame_jpegs: list[bytes]) -> DescriptionResult:
|
|
50
|
+
if not frame_jpegs:
|
|
51
|
+
raise ValueError("No frame images were provided to the AI client")
|
|
52
|
+
|
|
53
|
+
content: list[dict[str, object]] = [
|
|
54
|
+
{
|
|
55
|
+
"type": "text",
|
|
56
|
+
"text": (
|
|
57
|
+
"You are an expert film archiver. Analyze these sequential frames "
|
|
58
|
+
"from a video and respond with valid JSON only. You are allowed to use explicit/adult language in case of NSFW context. "
|
|
59
|
+
'Schema: {"title":"...", "short_summary":"...", "generated_description":"..."}. '
|
|
60
|
+
"Requirements: title should be catchy and concise, short_summary must be exactly 2 sentences, "
|
|
61
|
+
"generated_description must be 3 paragraphs with rich plot/detail context."
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
for frame in frame_jpegs:
|
|
67
|
+
encoded = base64.b64encode(frame).decode("ascii")
|
|
68
|
+
content.append(
|
|
69
|
+
{
|
|
70
|
+
"type": "image_url",
|
|
71
|
+
"image_url": {"url": f"data:image/jpeg;base64,{encoded}"},
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
payload = {
|
|
76
|
+
"model": self.model,
|
|
77
|
+
"messages": [{"role": "user", "content": content}],
|
|
78
|
+
"temperature": 0.4,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
headers = {
|
|
82
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
}
|
|
85
|
+
if self.is_openrouter:
|
|
86
|
+
headers["HTTP-Referer"] = "https://github.com/h4nz4/synop"
|
|
87
|
+
headers["X-Title"] = "synop"
|
|
88
|
+
|
|
89
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
90
|
+
response = await client.post(
|
|
91
|
+
self.chat_completions_url,
|
|
92
|
+
headers=headers,
|
|
93
|
+
json=payload,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if response.status_code >= 400:
|
|
97
|
+
self._raise_http_error(response)
|
|
98
|
+
|
|
99
|
+
data = response.json()
|
|
100
|
+
raw_content = _extract_assistant_content(data)
|
|
101
|
+
return _parse_description(raw_content)
|
|
102
|
+
|
|
103
|
+
def _raise_http_error(self, response: httpx.Response) -> None:
|
|
104
|
+
body_text = response.text
|
|
105
|
+
status = response.status_code
|
|
106
|
+
lowered = body_text.lower()
|
|
107
|
+
|
|
108
|
+
if status == 401:
|
|
109
|
+
provider = "OpenRouter" if self.is_openrouter else "API"
|
|
110
|
+
raise OpenRouterError(
|
|
111
|
+
f"{provider} authentication failed. Check SYNOP_API_KEY or saved key."
|
|
112
|
+
)
|
|
113
|
+
if status == 429:
|
|
114
|
+
provider = "OpenRouter" if self.is_openrouter else "API"
|
|
115
|
+
raise OpenRouterError(
|
|
116
|
+
f"{provider} rate limit reached. Retry later or use another model."
|
|
117
|
+
)
|
|
118
|
+
if (
|
|
119
|
+
status == 400
|
|
120
|
+
and "image" in lowered
|
|
121
|
+
and ("unsupported" in lowered or "not support" in lowered)
|
|
122
|
+
):
|
|
123
|
+
raise OpenRouterError(
|
|
124
|
+
f"Model '{self.model}' does not appear to support vision input."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
raise OpenRouterError(f"OpenRouter request failed ({status}): {body_text}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _extract_assistant_content(payload: dict[str, object]) -> str:
|
|
131
|
+
choices = payload.get("choices")
|
|
132
|
+
if not isinstance(choices, list) or not choices:
|
|
133
|
+
raise OpenRouterError("OpenRouter response is missing choices")
|
|
134
|
+
|
|
135
|
+
first_choice = choices[0]
|
|
136
|
+
if not isinstance(first_choice, dict):
|
|
137
|
+
raise OpenRouterError("OpenRouter response has invalid choice format")
|
|
138
|
+
|
|
139
|
+
message = first_choice.get("message")
|
|
140
|
+
if not isinstance(message, dict):
|
|
141
|
+
raise OpenRouterError("OpenRouter response is missing message payload")
|
|
142
|
+
|
|
143
|
+
content = message.get("content")
|
|
144
|
+
if isinstance(content, str):
|
|
145
|
+
return content.strip()
|
|
146
|
+
|
|
147
|
+
if isinstance(content, list):
|
|
148
|
+
parts: list[str] = []
|
|
149
|
+
for item in content:
|
|
150
|
+
if isinstance(item, dict):
|
|
151
|
+
text = item.get("text")
|
|
152
|
+
if isinstance(text, str):
|
|
153
|
+
parts.append(text)
|
|
154
|
+
if parts:
|
|
155
|
+
return "\n".join(parts).strip()
|
|
156
|
+
|
|
157
|
+
raise OpenRouterError("OpenRouter response did not include text content")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_description(content: str) -> DescriptionResult:
|
|
161
|
+
data = _parse_json_maybe(content)
|
|
162
|
+
if data is not None:
|
|
163
|
+
title = str(data.get("title") or "Untitled")
|
|
164
|
+
short_summary = str(data.get("short_summary") or "No short summary provided.")
|
|
165
|
+
generated_description = str(
|
|
166
|
+
data.get("generated_description") or "No detailed description provided."
|
|
167
|
+
)
|
|
168
|
+
return DescriptionResult(
|
|
169
|
+
title=title.strip(),
|
|
170
|
+
short_summary=short_summary.strip(),
|
|
171
|
+
generated_description=generated_description.strip(),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
plain = content.strip()
|
|
175
|
+
short_summary = plain[:220].strip()
|
|
176
|
+
if short_summary and not short_summary.endswith("."):
|
|
177
|
+
short_summary += "."
|
|
178
|
+
return DescriptionResult(
|
|
179
|
+
title="Generated Video Description",
|
|
180
|
+
short_summary=short_summary or "No short summary provided.",
|
|
181
|
+
generated_description=plain or "No detailed description provided.",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _parse_json_maybe(content: str) -> dict[str, object] | None:
|
|
186
|
+
stripped = content.strip()
|
|
187
|
+
try:
|
|
188
|
+
loaded = json.loads(stripped)
|
|
189
|
+
if isinstance(loaded, dict):
|
|
190
|
+
return loaded
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
fenced_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", stripped, re.DOTALL)
|
|
195
|
+
if fenced_match:
|
|
196
|
+
candidate = fenced_match.group(1)
|
|
197
|
+
try:
|
|
198
|
+
loaded = json.loads(candidate)
|
|
199
|
+
if isinstance(loaded, dict):
|
|
200
|
+
return loaded
|
|
201
|
+
except json.JSONDecodeError:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
bracket_match = re.search(r"\{.*\}", stripped, re.DOTALL)
|
|
205
|
+
if bracket_match:
|
|
206
|
+
candidate = bracket_match.group(0)
|
|
207
|
+
try:
|
|
208
|
+
loaded = json.loads(candidate)
|
|
209
|
+
if isinstance(loaded, dict):
|
|
210
|
+
return loaded
|
|
211
|
+
except json.JSONDecodeError:
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
return None
|