@danhachuel/thunderbolt 0.6.64 → 0.6.66
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.
- package/app/main.py +9 -9
- package/hermes_ui/video_length.py +29 -14
- package/integrations/instagram_public.py +18 -3
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -116,7 +116,7 @@ from hermes_ui.update_manager import check_version, restart_current_process, upd
|
|
|
116
116
|
|
|
117
117
|
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
118
118
|
from hermes_ui.script_generation import generate_script_document
|
|
119
|
-
from hermes_ui.video_length import DEFAULT_AVERAGE_VIDEO_TIME, channel_video_length, length_generation_settings, words_from_channel_time
|
|
119
|
+
from hermes_ui.video_length import DEFAULT_AVERAGE_VIDEO_TIME, channel_video_length, channel_video_time_value, length_generation_settings, words_from_channel_time
|
|
120
120
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
121
121
|
from hermes_ui.elevenlabs_voices import ElevenLabsVoicesError, cached_personal_voices, fetch_personal_voices, personal_voice_options
|
|
122
122
|
from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
|
|
@@ -2374,10 +2374,10 @@ def render_tiktok_channels():
|
|
|
2374
2374
|
prompt_file = TIKTOK_PROMPT_MASTERS / str(channel.get("default_prompt_master") or channel.get("prompt_master") or "")
|
|
2375
2375
|
prompt_content = load_prompt_master_file(prompt_file) if prompt_file.is_file() else ""
|
|
2376
2376
|
calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel, prompt_master=prompt_content)
|
|
2377
|
-
average_video_time = st.text_input("Tempo Medio de Video
|
|
2377
|
+
average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"tiktok_channel_average_video_time_{channel_id}", help=f"Referência: {calculated_words or 0} palavras · {calculated_source}.")
|
|
2378
2378
|
if st.button("Guardar tempo", key=f"save_tiktok_channel_average_video_time_{channel_id}", use_container_width=True):
|
|
2379
2379
|
if not valid_hhmm(average_video_time):
|
|
2380
|
-
st.error("Use o formato
|
|
2380
|
+
st.error("Use o formato MM:SS, por exemplo 12:00.")
|
|
2381
2381
|
else:
|
|
2382
2382
|
update_channel(channel_id, {"average_video_time": average_video_time.strip() or DEFAULT_AVERAGE_VIDEO_TIME, "average_video_word_count": words_from_channel_time(average_video_time)})
|
|
2383
2383
|
st.success("Tempo médio do canal TikTok guardado.")
|
|
@@ -3073,10 +3073,10 @@ def render_channels():
|
|
|
3073
3073
|
st.metric("Visualizações", _format_channel_count(channel.get("view_count")))
|
|
3074
3074
|
with header_cols[5]:
|
|
3075
3075
|
calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel)
|
|
3076
|
-
average_video_time = st.text_input("Tempo Medio de Video
|
|
3076
|
+
average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"youtube_channel_average_video_time_{channel_id}", help=f"Referência: {calculated_words or 0} palavras · {calculated_source}.")
|
|
3077
3077
|
if st.button("Guardar tempo", key=f"save_youtube_channel_average_video_time_{channel_id}", use_container_width=True):
|
|
3078
3078
|
if not valid_hhmm(average_video_time):
|
|
3079
|
-
st.error("Use o formato
|
|
3079
|
+
st.error("Use o formato MM:SS, por exemplo 12:00.")
|
|
3080
3080
|
else:
|
|
3081
3081
|
update_channel(channel_id, {"average_video_time": average_video_time.strip() or DEFAULT_AVERAGE_VIDEO_TIME, "average_video_word_count": words_from_channel_time(average_video_time)})
|
|
3082
3082
|
st.success("Tempo médio do canal YouTube guardado.")
|
|
@@ -5465,7 +5465,7 @@ def render_tiktok_automation():
|
|
|
5465
5465
|
prompt_file = TIKTOK_PROMPT_MASTERS / str(channel.get("default_prompt_master") or channel.get("prompt_master") or "")
|
|
5466
5466
|
prompt_content = load_prompt_master_file(prompt_file) if prompt_file.is_file() else ""
|
|
5467
5467
|
calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel, prompt_master=prompt_content)
|
|
5468
|
-
average_video_time = st.text_input("Tempo Medio de Video
|
|
5468
|
+
average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"tiktok_average_video_time_{channel_id}", help=f"Referência média: {calculated_words or 0} palavras · fonte: {calculated_source}.")
|
|
5469
5469
|
default_cols = st.columns([1.0, 1.0, 1.35, 1.45, 1.55, 1.0], gap="small")
|
|
5470
5470
|
with default_cols[0]:
|
|
5471
5471
|
st.markdown("**Idioma do roteiro**")
|
|
@@ -5489,7 +5489,7 @@ def render_tiktok_automation():
|
|
|
5489
5489
|
if not valid_hhmm(schedule_time):
|
|
5490
5490
|
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
|
5491
5491
|
elif not valid_hhmm(average_video_time):
|
|
5492
|
-
st.error("O Tempo Medio de Video deve estar no formato
|
|
5492
|
+
st.error("O Tempo Medio de Video deve estar no formato MM:SS, por exemplo 12:00.")
|
|
5493
5493
|
else:
|
|
5494
5494
|
avatar_url = _tiktok_avatar_url(channel)
|
|
5495
5495
|
update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip(), "average_video_time": average_video_time.strip() or DEFAULT_AVERAGE_VIDEO_TIME, "average_video_word_count": words_from_channel_time(average_video_time), "default_prompt_master": prompt, "prompt_master": prompt, "platform": "tiktok", "format": automation_format, "video_aspect_ratio": "Portrait 9:16", "style_wide": "portrait", "avatar_url": avatar_url, "thumbnail_url": avatar_url})
|
|
@@ -5653,7 +5653,7 @@ def render_automation():
|
|
|
5653
5653
|
schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
|
|
5654
5654
|
with header_cols[7]:
|
|
5655
5655
|
calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel)
|
|
5656
|
-
average_video_time = st.text_input("Tempo Medio de Video
|
|
5656
|
+
average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"average_video_time_{channel_id}", help=f"Referência média: {calculated_words or 0} palavras · fonte: {calculated_source}.")
|
|
5657
5657
|
|
|
5658
5658
|
control_cols = st.columns([1.8, 1.8, 1.35, 1.2], gap="small")
|
|
5659
5659
|
with control_cols[0]:
|
|
@@ -5680,7 +5680,7 @@ def render_automation():
|
|
|
5680
5680
|
if not valid_hhmm(schedule_time):
|
|
5681
5681
|
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
|
5682
5682
|
elif not valid_hhmm(average_video_time):
|
|
5683
|
-
st.error("O Tempo Medio de Video deve estar no formato
|
|
5683
|
+
st.error("O Tempo Medio de Video deve estar no formato MM:SS, por exemplo 12:00.")
|
|
5684
5684
|
else:
|
|
5685
5685
|
update_channel(channel_id, {
|
|
5686
5686
|
"automation_on": bool(enabled),
|
|
@@ -6,32 +6,43 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
8
|
WORDS_PER_MINUTE = 150
|
|
9
|
-
DEFAULT_AVERAGE_VIDEO_TIME = "00
|
|
9
|
+
DEFAULT_AVERAGE_VIDEO_TIME = "12:00"
|
|
10
10
|
AVERAGE_VIDEO_TIME_KEY = "average_video_time"
|
|
11
11
|
AVERAGE_VIDEO_WORD_COUNT_KEY = "average_video_word_count"
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def channel_video_time_value(channel: dict[str, Any]) -> str:
|
|
15
|
+
"""Return the card value, migrating only the historical twelve-minute default."""
|
|
16
|
+
value = str(channel.get(AVERAGE_VIDEO_TIME_KEY) or "").strip()
|
|
17
|
+
configured_words = str(channel.get(AVERAGE_VIDEO_WORD_COUNT_KEY) or "").strip()
|
|
18
|
+
if value == "00:12" and configured_words in {"", "0", "1800"}:
|
|
19
|
+
return DEFAULT_AVERAGE_VIDEO_TIME
|
|
20
|
+
return value or DEFAULT_AVERAGE_VIDEO_TIME
|
|
21
|
+
|
|
22
|
+
|
|
14
23
|
def valid_hhmm(value: Any) -> bool:
|
|
24
|
+
"""Keep the historical helper name while validating a MM:SS duration."""
|
|
15
25
|
match = re.fullmatch(r"\s*(\d{1,3}):(\d{2})\s*", str(value or ""))
|
|
16
26
|
if not match:
|
|
17
27
|
return False
|
|
18
|
-
|
|
19
|
-
return
|
|
28
|
+
minutes, seconds = int(match.group(1)), int(match.group(2))
|
|
29
|
+
return minutes >= 0 and 0 <= seconds <= 59
|
|
20
30
|
|
|
21
31
|
|
|
22
|
-
def minutes_from_hhmm(value: Any) ->
|
|
32
|
+
def minutes_from_hhmm(value: Any) -> float:
|
|
33
|
+
"""Convert a stored MM:SS duration to fractional minutes."""
|
|
23
34
|
if not valid_hhmm(value):
|
|
24
35
|
return 0
|
|
25
|
-
|
|
26
|
-
return
|
|
36
|
+
minutes, seconds = (int(part) for part in str(value).strip().split(":"))
|
|
37
|
+
return minutes + seconds / 60
|
|
27
38
|
|
|
28
39
|
|
|
29
40
|
def hhmm_from_minutes(value: Any) -> str:
|
|
30
41
|
try:
|
|
31
|
-
|
|
42
|
+
total_seconds = max(0, round(float(value) * 60))
|
|
32
43
|
except (TypeError, ValueError):
|
|
33
|
-
|
|
34
|
-
return f"{
|
|
44
|
+
total_seconds = 0
|
|
45
|
+
return f"{total_seconds // 60:02d}:{total_seconds % 60:02d}"
|
|
35
46
|
|
|
36
47
|
|
|
37
48
|
def _numbers(value: Any) -> list[int]:
|
|
@@ -91,23 +102,26 @@ def explicit_word_count(blueprint: Any = None, prompt_master: str = "") -> int:
|
|
|
91
102
|
|
|
92
103
|
|
|
93
104
|
def channel_video_length(channel: dict[str, Any], blueprint: Any = None, prompt_master: str = "") -> tuple[int, str, str]:
|
|
94
|
-
"""Return (word_count,
|
|
95
|
-
|
|
105
|
+
"""Return (word_count, mm:ss, source), preferring channel override over defaults."""
|
|
106
|
+
raw_configured_time = str(channel.get(AVERAGE_VIDEO_TIME_KEY) or "").strip()
|
|
96
107
|
configured_words = int(channel.get(AVERAGE_VIDEO_WORD_COUNT_KEY) or 0) if str(channel.get(AVERAGE_VIDEO_WORD_COUNT_KEY) or "0").isdigit() else 0
|
|
108
|
+
if raw_configured_time == "00:12" and configured_words in {0, 1800}:
|
|
109
|
+
raw_configured_time = DEFAULT_AVERAGE_VIDEO_TIME
|
|
110
|
+
configured_time = minutes_from_hhmm(raw_configured_time)
|
|
97
111
|
if configured_time:
|
|
98
|
-
return configured_time * WORDS_PER_MINUTE, hhmm_from_minutes(configured_time), "canal"
|
|
112
|
+
return round(configured_time * WORDS_PER_MINUTE), hhmm_from_minutes(configured_time), "canal"
|
|
99
113
|
if configured_words:
|
|
100
114
|
return configured_words, hhmm_from_minutes(round(configured_words / WORDS_PER_MINUTE)), "canal"
|
|
101
115
|
words = explicit_word_count(blueprint, prompt_master)
|
|
102
116
|
if words:
|
|
103
117
|
return words, hhmm_from_minutes(round(words / WORDS_PER_MINUTE)), "Blueprint/Prompt Master"
|
|
104
118
|
default_minutes = minutes_from_hhmm(DEFAULT_AVERAGE_VIDEO_TIME)
|
|
105
|
-
return default_minutes * WORDS_PER_MINUTE, DEFAULT_AVERAGE_VIDEO_TIME, "padrão do canal"
|
|
119
|
+
return round(default_minutes * WORDS_PER_MINUTE), DEFAULT_AVERAGE_VIDEO_TIME, "padrão do canal"
|
|
106
120
|
|
|
107
121
|
|
|
108
122
|
def words_from_channel_time(value: Any) -> int:
|
|
109
123
|
minutes = minutes_from_hhmm(value)
|
|
110
|
-
return minutes * WORDS_PER_MINUTE if minutes else 0
|
|
124
|
+
return round(minutes * WORDS_PER_MINUTE) if minutes else 0
|
|
111
125
|
|
|
112
126
|
|
|
113
127
|
def length_generation_settings(channel: dict[str, Any], blueprint: Any = None, prompt_master: str = "") -> dict[str, Any]:
|
|
@@ -124,6 +138,7 @@ __all__ = [
|
|
|
124
138
|
"AVERAGE_VIDEO_TIME_KEY",
|
|
125
139
|
"AVERAGE_VIDEO_WORD_COUNT_KEY",
|
|
126
140
|
"DEFAULT_AVERAGE_VIDEO_TIME",
|
|
141
|
+
"channel_video_time_value",
|
|
127
142
|
"WORDS_PER_MINUTE",
|
|
128
143
|
"channel_video_length",
|
|
129
144
|
"explicit_word_count",
|
|
@@ -605,10 +605,25 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
|
|
|
605
605
|
return IntegrationResult(False, 'Esta conta é privada. O Instagram não disponibiliza posts sem uma sessão autenticada.', reference | {'posts': [], 'is_private': True})
|
|
606
606
|
if platform.system() == 'Windows':
|
|
607
607
|
html_posts = api_user.get('_html_posts') or []
|
|
608
|
-
|
|
608
|
+
print(f'Posts no user inicial para @{reference["username"]}: {len(html_posts)}')
|
|
609
|
+
if html_posts:
|
|
610
|
+
return IntegrationResult(True, 'Posts públicos encontrados.', reference | {'posts': html_posts[:max(1, int(limit))]})
|
|
609
611
|
if platform.system() == 'Windows':
|
|
610
612
|
html_content = _fetch_instagram_html_with_playwright(reference['url'], reference['username'])
|
|
611
|
-
|
|
613
|
+
html_user = _extract_profile_user_from_html(html_content, reference['username']) if html_content else {}
|
|
614
|
+
media = html_user.get('edge_owner_to_timeline_media') if isinstance(html_user, Mapping) else {}
|
|
615
|
+
edges = media.get('edges') if isinstance(media, Mapping) else []
|
|
616
|
+
print(f'JSON do perfil extraído no Windows para @{reference["username"]}: {bool(html_user)}; edges encontrados: {len(edges or [])}')
|
|
617
|
+
posts: list[dict[str, Any]] = []
|
|
618
|
+
for edge in edges or []:
|
|
619
|
+
node = edge.get('node') if isinstance(edge, Mapping) else edge
|
|
620
|
+
if isinstance(node, dict):
|
|
621
|
+
post = _post_from_node(node)
|
|
622
|
+
if post:
|
|
623
|
+
posts.append(post)
|
|
624
|
+
if not posts:
|
|
625
|
+
posts = _extract_posts_from_html(html_content, limit) if html_content else []
|
|
626
|
+
posts = posts[:max(1, int(limit))]
|
|
612
627
|
print(f'Posts extraídos no Windows para @{reference["username"]}: {len(posts)}')
|
|
613
628
|
if not posts and html_content:
|
|
614
629
|
try:
|
|
@@ -618,7 +633,7 @@ def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationRes
|
|
|
618
633
|
print(f'HTML de debug dos posts guardado em storage/debug_posts.html para @{reference["username"]}')
|
|
619
634
|
except OSError as exc:
|
|
620
635
|
print(f'Não foi possível guardar storage/debug_posts.html: {exc}')
|
|
621
|
-
return IntegrationResult(bool(posts), 'Posts públicos encontrados.' if posts else 'Não foi possível encontrar posts públicos nesta página do Instagram.', reference | {'posts': posts
|
|
636
|
+
return IntegrationResult(bool(posts), 'Posts públicos encontrados.' if posts else 'Não foi possível encontrar posts públicos nesta página do Instagram.', reference | {'posts': posts})
|
|
622
637
|
try:
|
|
623
638
|
response = requests.get(reference['url'], headers={'User-Agent': 'Mozilla/5.0', 'Accept-Language': 'pt-BR,pt;q=0.9,en;q=0.8'}, timeout=15)
|
|
624
639
|
except requests.RequestException as exc:
|
package/package.json
CHANGED