elidia-agent-cli 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.
- elidia/__init__.py +0 -0
- elidia/__main__.py +4 -0
- elidia/agent/__init__.py +3 -0
- elidia/agent/loop.py +449 -0
- elidia/agent/personas.py +130 -0
- elidia/agent/portal.py +115 -0
- elidia/api/__init__.py +0 -0
- elidia/api/client.py +221 -0
- elidia/api/streaming.py +55 -0
- elidia/auth/__init__.py +0 -0
- elidia/auth/keychain.py +86 -0
- elidia/cache/__init__.py +1 -0
- elidia/cache/lru.py +158 -0
- elidia/cli/__init__.py +0 -0
- elidia/cli/commands.py +122 -0
- elidia/cli/main.py +293 -0
- elidia/cli/pager.py +104 -0
- elidia/cli/progress.py +173 -0
- elidia/cli/renderer.py +32 -0
- elidia/cli/repl.py +1234 -0
- elidia/cli/themes.py +214 -0
- elidia/config/__init__.py +0 -0
- elidia/config/defaults.py +5 -0
- elidia/config/rules.py +48 -0
- elidia/config/settings.py +217 -0
- elidia/creative/__init__.py +1 -0
- elidia/creative/audio.py +213 -0
- elidia/creative/display.py +260 -0
- elidia/creative/image.py +142 -0
- elidia/creative/local.py +163 -0
- elidia/creative/video.py +181 -0
- elidia/daemon/__init__.py +0 -0
- elidia/daemon/manager.py +349 -0
- elidia/db/__init__.py +0 -0
- elidia/db/database.py +87 -0
- elidia/mcp/__init__.py +0 -0
- elidia/mcp/client.py +198 -0
- elidia/mcp/config.py +93 -0
- elidia/mcp/registry.py +101 -0
- elidia/mcp/types.py +43 -0
- elidia/memory/__init__.py +3 -0
- elidia/memory/auto.py +164 -0
- elidia/memory/compaction.py +101 -0
- elidia/memory/embeddings.py +47 -0
- elidia/memory/outcomes.py +131 -0
- elidia/memory/patterns.py +79 -0
- elidia/memory/store.py +338 -0
- elidia/models/__init__.py +0 -0
- elidia/models/adaptive.py +53 -0
- elidia/models/router.py +115 -0
- elidia/modes/__init__.py +1 -0
- elidia/modes/autonomous.py +318 -0
- elidia/modes/budget.py +182 -0
- elidia/modes/classifier.py +169 -0
- elidia/modes/consensus.py +210 -0
- elidia/modes/deep_think.py +190 -0
- elidia/modes/swarm.py +229 -0
- elidia/modes/thinking.py +153 -0
- elidia/permissions/__init__.py +0 -0
- elidia/permissions/audit.py +138 -0
- elidia/permissions/manager.py +199 -0
- elidia/permissions/trust.py +63 -0
- elidia/rag/__init__.py +4 -0
- elidia/rag/chunker.py +216 -0
- elidia/rag/engine.py +345 -0
- elidia/rag/ingest.py +154 -0
- elidia/rag/portal_bridge.py +84 -0
- elidia/rag/watcher.py +98 -0
- elidia/research/__init__.py +1 -0
- elidia/research/export.py +237 -0
- elidia/research/orchestrator.py +310 -0
- elidia/research/sources.py +223 -0
- elidia/session/__init__.py +0 -0
- elidia/session/history.py +119 -0
- elidia/session/manager.py +82 -0
- elidia/tools/__init__.py +28 -0
- elidia/tools/base.py +70 -0
- elidia/tools/fetch.py +100 -0
- elidia/tools/filesystem.py +204 -0
- elidia/tools/git.py +156 -0
- elidia/tools/search.py +99 -0
- elidia/tools/terminal.py +77 -0
- elidia/widgets/__init__.py +1 -0
- elidia/widgets/protocol.py +248 -0
- elidia/widgets/renderer.py +225 -0
- elidia/workflow/__init__.py +1 -0
- elidia/workflow/engine.py +377 -0
- elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
- elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
- elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
- elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
- elidia_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
elidia/creative/audio.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Audio generation via AiUtils Developer API.
|
|
2
|
+
|
|
3
|
+
Text-to-speech (ElevenLabs, OpenAI TTS) and music generation (Suno).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from elidia.api.client import AiUtilsClient
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
TTS_MODELS = {
|
|
21
|
+
"elevenlabs-v2": {"vendor": "elevenlabs", "voices": ["rachel", "adam", "josh", "bella", "elli"]},
|
|
22
|
+
"tts-1": {"vendor": "openai", "voices": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]},
|
|
23
|
+
"tts-1-hd": {"vendor": "openai", "voices": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
MUSIC_MODELS = {
|
|
27
|
+
"suno-v4": {"vendor": "suno", "max_duration": 240},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
DEFAULT_TTS_MODEL = "tts-1"
|
|
31
|
+
DEFAULT_MUSIC_MODEL = "suno-v4"
|
|
32
|
+
DEFAULT_OUTPUT_DIR = Path.home() / ".elidia" / "generated" / "audio"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class AudioResult:
|
|
37
|
+
file_path: str
|
|
38
|
+
model: str
|
|
39
|
+
text: str
|
|
40
|
+
voice: str
|
|
41
|
+
format: str = "mp3"
|
|
42
|
+
size_bytes: int = 0
|
|
43
|
+
elapsed_ms: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class MusicResult:
|
|
48
|
+
file_path: str
|
|
49
|
+
model: str
|
|
50
|
+
prompt: str
|
|
51
|
+
duration: int
|
|
52
|
+
format: str = "mp3"
|
|
53
|
+
size_bytes: int = 0
|
|
54
|
+
elapsed_ms: int = 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def generate_speech(
|
|
58
|
+
client: AiUtilsClient,
|
|
59
|
+
text: str,
|
|
60
|
+
model: str = DEFAULT_TTS_MODEL,
|
|
61
|
+
voice: str = "alloy",
|
|
62
|
+
output_dir: Path | None = None,
|
|
63
|
+
) -> AudioResult:
|
|
64
|
+
logger.debug(f"Entered into generate_speech: model={model}, voice={voice}, text_len={len(text)}")
|
|
65
|
+
start = time.monotonic()
|
|
66
|
+
|
|
67
|
+
save_dir = output_dir or DEFAULT_OUTPUT_DIR
|
|
68
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
payload: dict[str, Any] = {
|
|
71
|
+
"model": model,
|
|
72
|
+
"input": text,
|
|
73
|
+
"voice": voice,
|
|
74
|
+
"response_format": "mp3",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async with httpx.AsyncClient(
|
|
78
|
+
base_url=client._base_url,
|
|
79
|
+
headers={
|
|
80
|
+
"Authorization": f"Bearer {client._api_key}",
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
},
|
|
83
|
+
timeout=httpx.Timeout(60.0, connect=10.0),
|
|
84
|
+
) as http:
|
|
85
|
+
response = await http.post("/audio/speech", json=payload)
|
|
86
|
+
|
|
87
|
+
if response.status_code == 401:
|
|
88
|
+
raise RuntimeError("Invalid API key for audio generation")
|
|
89
|
+
if response.status_code == 402:
|
|
90
|
+
raise RuntimeError("Insufficient balance for audio generation")
|
|
91
|
+
response.raise_for_status()
|
|
92
|
+
|
|
93
|
+
content_type = response.headers.get("content-type", "")
|
|
94
|
+
|
|
95
|
+
if "audio" in content_type or "octet-stream" in content_type:
|
|
96
|
+
audio_bytes = response.content
|
|
97
|
+
else:
|
|
98
|
+
data = response.json()
|
|
99
|
+
b64 = data.get("audio", data.get("data", ""))
|
|
100
|
+
if b64:
|
|
101
|
+
audio_bytes = base64.b64decode(b64)
|
|
102
|
+
else:
|
|
103
|
+
raise RuntimeError("No audio data in response")
|
|
104
|
+
|
|
105
|
+
timestamp = int(time.time())
|
|
106
|
+
safe_text = "".join(c if c.isalnum() or c in " -_" else "" for c in text[:30]).strip().replace(" ", "_")
|
|
107
|
+
filename = f"{timestamp}_{voice}_{safe_text}.mp3"
|
|
108
|
+
file_path = save_dir / filename
|
|
109
|
+
file_path.write_bytes(audio_bytes)
|
|
110
|
+
|
|
111
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
112
|
+
logger.info(f"Speech generated: {file_path} ({len(audio_bytes)} bytes, {elapsed}ms)")
|
|
113
|
+
|
|
114
|
+
return AudioResult(
|
|
115
|
+
file_path=str(file_path),
|
|
116
|
+
model=model,
|
|
117
|
+
text=text,
|
|
118
|
+
voice=voice,
|
|
119
|
+
format="mp3",
|
|
120
|
+
size_bytes=len(audio_bytes),
|
|
121
|
+
elapsed_ms=elapsed,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def generate_music(
|
|
126
|
+
client: AiUtilsClient,
|
|
127
|
+
prompt: str,
|
|
128
|
+
model: str = DEFAULT_MUSIC_MODEL,
|
|
129
|
+
duration: int = 30,
|
|
130
|
+
output_dir: Path | None = None,
|
|
131
|
+
) -> MusicResult:
|
|
132
|
+
logger.debug(f"Entered into generate_music: model={model}, prompt={prompt[:60]!r}")
|
|
133
|
+
start = time.monotonic()
|
|
134
|
+
|
|
135
|
+
save_dir = output_dir or DEFAULT_OUTPUT_DIR
|
|
136
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
|
|
138
|
+
model_info = MUSIC_MODELS.get(model, MUSIC_MODELS[DEFAULT_MUSIC_MODEL])
|
|
139
|
+
duration = min(duration, model_info["max_duration"])
|
|
140
|
+
|
|
141
|
+
payload: dict[str, Any] = {
|
|
142
|
+
"model": model,
|
|
143
|
+
"prompt": prompt,
|
|
144
|
+
"duration": duration,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async with httpx.AsyncClient(
|
|
148
|
+
base_url=client._base_url,
|
|
149
|
+
headers={
|
|
150
|
+
"Authorization": f"Bearer {client._api_key}",
|
|
151
|
+
"Content-Type": "application/json",
|
|
152
|
+
},
|
|
153
|
+
timeout=httpx.Timeout(120.0, connect=10.0),
|
|
154
|
+
) as http:
|
|
155
|
+
response = await http.post("/audio/music", json=payload)
|
|
156
|
+
|
|
157
|
+
if response.status_code == 401:
|
|
158
|
+
raise RuntimeError("Invalid API key for music generation")
|
|
159
|
+
if response.status_code == 402:
|
|
160
|
+
raise RuntimeError("Insufficient balance for music generation")
|
|
161
|
+
response.raise_for_status()
|
|
162
|
+
|
|
163
|
+
content_type = response.headers.get("content-type", "")
|
|
164
|
+
|
|
165
|
+
if "audio" in content_type or "octet-stream" in content_type:
|
|
166
|
+
audio_bytes = response.content
|
|
167
|
+
else:
|
|
168
|
+
data = response.json()
|
|
169
|
+
b64 = data.get("audio", data.get("data", ""))
|
|
170
|
+
if b64:
|
|
171
|
+
audio_bytes = base64.b64decode(b64)
|
|
172
|
+
elif data.get("url"):
|
|
173
|
+
async with httpx.AsyncClient(timeout=60.0) as dl:
|
|
174
|
+
dl_resp = await dl.get(data["url"])
|
|
175
|
+
dl_resp.raise_for_status()
|
|
176
|
+
audio_bytes = dl_resp.content
|
|
177
|
+
else:
|
|
178
|
+
raise RuntimeError("No audio data or URL in response")
|
|
179
|
+
|
|
180
|
+
timestamp = int(time.time())
|
|
181
|
+
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30]).strip().replace(" ", "_")
|
|
182
|
+
filename = f"{timestamp}_music_{safe_prompt}.mp3"
|
|
183
|
+
file_path = save_dir / filename
|
|
184
|
+
file_path.write_bytes(audio_bytes)
|
|
185
|
+
|
|
186
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
187
|
+
logger.info(f"Music generated: {file_path} ({len(audio_bytes)} bytes, {elapsed}ms)")
|
|
188
|
+
|
|
189
|
+
return MusicResult(
|
|
190
|
+
file_path=str(file_path),
|
|
191
|
+
model=model,
|
|
192
|
+
prompt=prompt,
|
|
193
|
+
duration=duration,
|
|
194
|
+
format="mp3",
|
|
195
|
+
size_bytes=len(audio_bytes),
|
|
196
|
+
elapsed_ms=elapsed,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def list_tts_voices(model: str = DEFAULT_TTS_MODEL) -> list[str]:
|
|
201
|
+
logger.debug(f"Entered into list_tts_voices: model={model}")
|
|
202
|
+
info = TTS_MODELS.get(model, {})
|
|
203
|
+
return info.get("voices", [])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def list_audio_models() -> list[dict[str, Any]]:
|
|
207
|
+
logger.debug("Entered into list_audio_models")
|
|
208
|
+
result: list[dict[str, Any]] = []
|
|
209
|
+
for name, info in TTS_MODELS.items():
|
|
210
|
+
result.append({"model": name, "type": "tts", "vendor": info["vendor"], "voices": info["voices"]})
|
|
211
|
+
for name, info in MUSIC_MODELS.items():
|
|
212
|
+
result.append({"model": name, "type": "music", "vendor": info["vendor"], "max_duration": info["max_duration"]})
|
|
213
|
+
return result
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Terminal image display — protocol detection and inline rendering.
|
|
2
|
+
|
|
3
|
+
Detects terminal capabilities and renders images inline using:
|
|
4
|
+
- iTerm2 inline images (OSC 1337)
|
|
5
|
+
- Kitty graphics protocol (ESC _G)
|
|
6
|
+
- Sixel graphics
|
|
7
|
+
- Fallback: save to file and print path
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import struct
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TerminalProtocol(str, Enum):
|
|
24
|
+
ITERM2 = "iterm2"
|
|
25
|
+
KITTY = "kitty"
|
|
26
|
+
SIXEL = "sixel"
|
|
27
|
+
NONE = "none"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class DisplayResult:
|
|
32
|
+
protocol: TerminalProtocol
|
|
33
|
+
displayed: bool
|
|
34
|
+
file_path: str = ""
|
|
35
|
+
message: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def detect_terminal() -> TerminalProtocol:
|
|
39
|
+
logger.debug("Entered into detect_terminal")
|
|
40
|
+
|
|
41
|
+
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
|
42
|
+
if term_program in ("iterm.app", "iterm2"):
|
|
43
|
+
return TerminalProtocol.ITERM2
|
|
44
|
+
|
|
45
|
+
if os.environ.get("KITTY_WINDOW_ID"):
|
|
46
|
+
return TerminalProtocol.KITTY
|
|
47
|
+
|
|
48
|
+
term = os.environ.get("TERM", "")
|
|
49
|
+
if "xterm" in term:
|
|
50
|
+
lc_terminal = os.environ.get("LC_TERMINAL", "").lower()
|
|
51
|
+
if lc_terminal == "iterm2":
|
|
52
|
+
return TerminalProtocol.ITERM2
|
|
53
|
+
|
|
54
|
+
if _check_sixel_support():
|
|
55
|
+
return TerminalProtocol.SIXEL
|
|
56
|
+
|
|
57
|
+
return TerminalProtocol.NONE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def display_image(
|
|
61
|
+
image_path: str | Path,
|
|
62
|
+
max_width: int = 80,
|
|
63
|
+
max_height: int = 24,
|
|
64
|
+
protocol: TerminalProtocol | None = None,
|
|
65
|
+
) -> DisplayResult:
|
|
66
|
+
logger.debug(f"Entered into display_image: path={image_path}")
|
|
67
|
+
path = Path(image_path)
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return DisplayResult(
|
|
70
|
+
protocol=TerminalProtocol.NONE,
|
|
71
|
+
displayed=False,
|
|
72
|
+
file_path=str(path),
|
|
73
|
+
message=f"File not found: {path}",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
proto = protocol or detect_terminal()
|
|
77
|
+
image_data = path.read_bytes()
|
|
78
|
+
|
|
79
|
+
if proto == TerminalProtocol.ITERM2:
|
|
80
|
+
return _display_iterm2(image_data, path, max_width)
|
|
81
|
+
elif proto == TerminalProtocol.KITTY:
|
|
82
|
+
return _display_kitty(image_data, path, max_width)
|
|
83
|
+
elif proto == TerminalProtocol.SIXEL:
|
|
84
|
+
return _display_sixel(image_data, path, max_width, max_height)
|
|
85
|
+
else:
|
|
86
|
+
return DisplayResult(
|
|
87
|
+
protocol=TerminalProtocol.NONE,
|
|
88
|
+
displayed=False,
|
|
89
|
+
file_path=str(path),
|
|
90
|
+
message=f"Image saved to: {path}",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _display_iterm2(data: bytes, path: Path, max_width: int) -> DisplayResult:
|
|
95
|
+
logger.debug(f"Entered into _display_iterm2: size={len(data)}")
|
|
96
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
97
|
+
|
|
98
|
+
filename = base64.b64encode(path.name.encode()).decode("ascii")
|
|
99
|
+
size = len(data)
|
|
100
|
+
|
|
101
|
+
seq = (
|
|
102
|
+
f"\033]1337;File=name={filename};size={size};"
|
|
103
|
+
f"inline=1;width=auto;height=auto;preserveAspectRatio=1:"
|
|
104
|
+
f"{b64}\a"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
sys.stdout.write(seq)
|
|
108
|
+
sys.stdout.write("\n")
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
|
|
111
|
+
return DisplayResult(
|
|
112
|
+
protocol=TerminalProtocol.ITERM2,
|
|
113
|
+
displayed=True,
|
|
114
|
+
file_path=str(path),
|
|
115
|
+
message=f"Displayed via iTerm2 protocol ({len(data)} bytes)",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _display_kitty(data: bytes, path: Path, max_width: int) -> DisplayResult:
|
|
120
|
+
logger.debug(f"Entered into _display_kitty: size={len(data)}")
|
|
121
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
122
|
+
|
|
123
|
+
chunk_size = 4096
|
|
124
|
+
chunks = [b64[i:i + chunk_size] for i in range(0, len(b64), chunk_size)]
|
|
125
|
+
|
|
126
|
+
for i, chunk in enumerate(chunks):
|
|
127
|
+
is_last = i == len(chunks) - 1
|
|
128
|
+
more = 0 if is_last else 1
|
|
129
|
+
|
|
130
|
+
if i == 0:
|
|
131
|
+
header = f"a=T,f=100,m={more}"
|
|
132
|
+
else:
|
|
133
|
+
header = f"m={more}"
|
|
134
|
+
|
|
135
|
+
sys.stdout.write(f"\033_G{header};{chunk}\033\\")
|
|
136
|
+
|
|
137
|
+
sys.stdout.write("\n")
|
|
138
|
+
sys.stdout.flush()
|
|
139
|
+
|
|
140
|
+
return DisplayResult(
|
|
141
|
+
protocol=TerminalProtocol.KITTY,
|
|
142
|
+
displayed=True,
|
|
143
|
+
file_path=str(path),
|
|
144
|
+
message=f"Displayed via Kitty protocol ({len(data)} bytes)",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _display_sixel(data: bytes, path: Path, max_width: int, max_height: int) -> DisplayResult:
|
|
149
|
+
logger.debug(f"Entered into _display_sixel: size={len(data)}")
|
|
150
|
+
try:
|
|
151
|
+
from PIL import Image
|
|
152
|
+
except ImportError:
|
|
153
|
+
return DisplayResult(
|
|
154
|
+
protocol=TerminalProtocol.SIXEL,
|
|
155
|
+
displayed=False,
|
|
156
|
+
file_path=str(path),
|
|
157
|
+
message="Sixel display requires Pillow: pip install Pillow",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
img = Image.open(path)
|
|
162
|
+
|
|
163
|
+
cell_w, cell_h = _get_cell_size()
|
|
164
|
+
max_px_w = max_width * cell_w
|
|
165
|
+
max_px_h = max_height * cell_h
|
|
166
|
+
|
|
167
|
+
w, h = img.size
|
|
168
|
+
if w > max_px_w or h > max_px_h:
|
|
169
|
+
ratio = min(max_px_w / w, max_px_h / h)
|
|
170
|
+
new_w = int(w * ratio)
|
|
171
|
+
new_h = int(h * ratio)
|
|
172
|
+
img = img.resize((new_w, new_h), Image.LANCZOS)
|
|
173
|
+
|
|
174
|
+
img = img.convert("P", palette=Image.ADAPTIVE, colors=256)
|
|
175
|
+
|
|
176
|
+
palette = img.getpalette()
|
|
177
|
+
pixels = list(img.getdata())
|
|
178
|
+
w, h = img.size
|
|
179
|
+
|
|
180
|
+
sixel_data = _encode_sixel(pixels, palette, w, h)
|
|
181
|
+
|
|
182
|
+
sys.stdout.write(f"\033P0;0;0q\"1;1;{w};{h}")
|
|
183
|
+
sys.stdout.write(sixel_data)
|
|
184
|
+
sys.stdout.write("\033\\")
|
|
185
|
+
sys.stdout.write("\n")
|
|
186
|
+
sys.stdout.flush()
|
|
187
|
+
|
|
188
|
+
return DisplayResult(
|
|
189
|
+
protocol=TerminalProtocol.SIXEL,
|
|
190
|
+
displayed=True,
|
|
191
|
+
file_path=str(path),
|
|
192
|
+
message=f"Displayed via Sixel ({w}x{h})",
|
|
193
|
+
)
|
|
194
|
+
except Exception as e:
|
|
195
|
+
return DisplayResult(
|
|
196
|
+
protocol=TerminalProtocol.SIXEL,
|
|
197
|
+
displayed=False,
|
|
198
|
+
file_path=str(path),
|
|
199
|
+
message=f"Sixel encoding failed: {e}",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _encode_sixel(pixels: list[int], palette: list[int] | None, width: int, height: int) -> str:
|
|
204
|
+
logger.debug(f"Entered into _encode_sixel: {width}x{height}")
|
|
205
|
+
lines: list[str] = []
|
|
206
|
+
|
|
207
|
+
if palette:
|
|
208
|
+
for i in range(0, min(len(palette), 768), 3):
|
|
209
|
+
color_idx = i // 3
|
|
210
|
+
r = int(palette[i] / 255 * 100)
|
|
211
|
+
g = int(palette[i + 1] / 255 * 100)
|
|
212
|
+
b = int(palette[i + 2] / 255 * 100)
|
|
213
|
+
lines.append(f"#{color_idx};2;{r};{g};{b}")
|
|
214
|
+
|
|
215
|
+
for band_y in range(0, height, 6):
|
|
216
|
+
band_colors: dict[int, list[int]] = {}
|
|
217
|
+
for dy in range(6):
|
|
218
|
+
y = band_y + dy
|
|
219
|
+
if y >= height:
|
|
220
|
+
break
|
|
221
|
+
for x in range(width):
|
|
222
|
+
px = pixels[y * width + x]
|
|
223
|
+
if px not in band_colors:
|
|
224
|
+
band_colors[px] = [0] * width
|
|
225
|
+
band_colors[px][x] |= 1 << dy
|
|
226
|
+
|
|
227
|
+
for color_idx, scanline in sorted(band_colors.items()):
|
|
228
|
+
lines.append(f"#{color_idx}")
|
|
229
|
+
for val in scanline:
|
|
230
|
+
lines.append(chr(63 + val))
|
|
231
|
+
lines.append("$")
|
|
232
|
+
|
|
233
|
+
lines.append("-")
|
|
234
|
+
|
|
235
|
+
return "".join(lines)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _check_sixel_support() -> bool:
|
|
239
|
+
logger.debug("Entered into _check_sixel_support")
|
|
240
|
+
term = os.environ.get("TERM", "")
|
|
241
|
+
if "sixel" in term.lower():
|
|
242
|
+
return True
|
|
243
|
+
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
|
244
|
+
if term_program in ("mlterm", "contour", "foot", "wezterm"):
|
|
245
|
+
return True
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _get_cell_size() -> tuple[int, int]:
|
|
250
|
+
logger.debug("Entered into _get_cell_size")
|
|
251
|
+
try:
|
|
252
|
+
import fcntl
|
|
253
|
+
import termios
|
|
254
|
+
result = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\x00" * 8)
|
|
255
|
+
rows, cols, xpixel, ypixel = struct.unpack("HHHH", result)
|
|
256
|
+
if xpixel > 0 and ypixel > 0 and cols > 0 and rows > 0:
|
|
257
|
+
return xpixel // cols, ypixel // rows
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
return 8, 16
|
elidia/creative/image.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Image generation via AiUtils Developer API.
|
|
2
|
+
|
|
3
|
+
Supports multiple models (FLUX, DALL-E, Stable Diffusion) through the
|
|
4
|
+
unified /v1/images/generations endpoint. Saves generated images to disk
|
|
5
|
+
and optionally displays them inline in supported terminals.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from elidia.api.client import AiUtilsClient
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
IMAGE_MODELS = {
|
|
23
|
+
"flux-1.1-pro": {"vendor": "bfl", "supports_size": True, "max_size": 2048},
|
|
24
|
+
"flux-schnell": {"vendor": "bfl", "supports_size": True, "max_size": 1024},
|
|
25
|
+
"dall-e-3": {"vendor": "openai", "supports_size": True, "max_size": 1792},
|
|
26
|
+
"dall-e-2": {"vendor": "openai", "supports_size": True, "max_size": 1024},
|
|
27
|
+
"sd-3.5-large": {"vendor": "stability", "supports_size": True, "max_size": 1024},
|
|
28
|
+
"sdxl-turbo": {"vendor": "stability", "supports_size": True, "max_size": 1024},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
DEFAULT_MODEL = "flux-schnell"
|
|
32
|
+
DEFAULT_OUTPUT_DIR = Path.home() / ".elidia" / "generated" / "images"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ImageResult:
|
|
37
|
+
file_path: str
|
|
38
|
+
model: str
|
|
39
|
+
prompt: str
|
|
40
|
+
width: int
|
|
41
|
+
height: int
|
|
42
|
+
format: str = "png"
|
|
43
|
+
size_bytes: int = 0
|
|
44
|
+
elapsed_ms: int = 0
|
|
45
|
+
base64_data: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def generate_image(
|
|
49
|
+
client: AiUtilsClient,
|
|
50
|
+
prompt: str,
|
|
51
|
+
model: str = DEFAULT_MODEL,
|
|
52
|
+
width: int = 1024,
|
|
53
|
+
height: int = 1024,
|
|
54
|
+
output_dir: Path | None = None,
|
|
55
|
+
) -> ImageResult:
|
|
56
|
+
logger.debug(f"Entered into generate_image: model={model}, prompt={prompt[:60]!r}")
|
|
57
|
+
start = time.monotonic()
|
|
58
|
+
|
|
59
|
+
save_dir = output_dir or DEFAULT_OUTPUT_DIR
|
|
60
|
+
save_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
model_info = IMAGE_MODELS.get(model, IMAGE_MODELS[DEFAULT_MODEL])
|
|
63
|
+
max_size = model_info["max_size"]
|
|
64
|
+
width = min(width, max_size)
|
|
65
|
+
height = min(height, max_size)
|
|
66
|
+
|
|
67
|
+
payload: dict[str, Any] = {
|
|
68
|
+
"model": model,
|
|
69
|
+
"prompt": prompt,
|
|
70
|
+
"n": 1,
|
|
71
|
+
"response_format": "b64_json",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if model_info["supports_size"]:
|
|
75
|
+
payload["size"] = f"{width}x{height}"
|
|
76
|
+
|
|
77
|
+
async with httpx.AsyncClient(
|
|
78
|
+
base_url=client._base_url,
|
|
79
|
+
headers={
|
|
80
|
+
"Authorization": f"Bearer {client._api_key}",
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
},
|
|
83
|
+
timeout=httpx.Timeout(120.0, connect=10.0),
|
|
84
|
+
) as http:
|
|
85
|
+
response = await http.post("/images/generations", json=payload)
|
|
86
|
+
|
|
87
|
+
if response.status_code == 401:
|
|
88
|
+
raise RuntimeError("Invalid API key for image generation")
|
|
89
|
+
if response.status_code == 402:
|
|
90
|
+
raise RuntimeError("Insufficient balance for image generation")
|
|
91
|
+
if response.status_code == 429:
|
|
92
|
+
raise RuntimeError("Rate limited — try again in a moment")
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
|
|
95
|
+
data = response.json()
|
|
96
|
+
|
|
97
|
+
images = data.get("data", [])
|
|
98
|
+
if not images:
|
|
99
|
+
raise RuntimeError("No image returned from API")
|
|
100
|
+
|
|
101
|
+
image_data = images[0]
|
|
102
|
+
b64 = image_data.get("b64_json", "")
|
|
103
|
+
if not b64:
|
|
104
|
+
url = image_data.get("url", "")
|
|
105
|
+
if url:
|
|
106
|
+
async with httpx.AsyncClient(timeout=30.0) as dl:
|
|
107
|
+
img_resp = await dl.get(url)
|
|
108
|
+
img_resp.raise_for_status()
|
|
109
|
+
img_bytes = img_resp.content
|
|
110
|
+
else:
|
|
111
|
+
raise RuntimeError("No image data or URL in response")
|
|
112
|
+
else:
|
|
113
|
+
img_bytes = base64.b64decode(b64)
|
|
114
|
+
|
|
115
|
+
timestamp = int(time.time())
|
|
116
|
+
safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:40]).strip().replace(" ", "_")
|
|
117
|
+
filename = f"{timestamp}_{safe_prompt}.png"
|
|
118
|
+
file_path = save_dir / filename
|
|
119
|
+
file_path.write_bytes(img_bytes)
|
|
120
|
+
|
|
121
|
+
elapsed = int((time.monotonic() - start) * 1000)
|
|
122
|
+
logger.info(f"Image generated: {file_path} ({len(img_bytes)} bytes, {elapsed}ms)")
|
|
123
|
+
|
|
124
|
+
return ImageResult(
|
|
125
|
+
file_path=str(file_path),
|
|
126
|
+
model=model,
|
|
127
|
+
prompt=prompt,
|
|
128
|
+
width=width,
|
|
129
|
+
height=height,
|
|
130
|
+
format="png",
|
|
131
|
+
size_bytes=len(img_bytes),
|
|
132
|
+
elapsed_ms=elapsed,
|
|
133
|
+
base64_data=b64 if b64 else base64.b64encode(img_bytes).decode("ascii"),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def list_image_models() -> list[dict[str, Any]]:
|
|
138
|
+
logger.debug("Entered into list_image_models")
|
|
139
|
+
return [
|
|
140
|
+
{"model": name, "vendor": info["vendor"], "max_size": info["max_size"]}
|
|
141
|
+
for name, info in IMAGE_MODELS.items()
|
|
142
|
+
]
|