@danhachuel/thunderbolt 0.6.65 → 0.6.67
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 +53 -0
- 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",
|
|
@@ -585,11 +585,64 @@ def _post_from_node(node: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
585
585
|
}
|
|
586
586
|
|
|
587
587
|
|
|
588
|
+
def _fetch_posts_with_ytdlp(username: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
589
|
+
"""Fetch public Instagram post metadata with the installed yt-dlp CLI."""
|
|
590
|
+
import subprocess
|
|
591
|
+
|
|
592
|
+
url = f'https://www.instagram.com/{username}/'
|
|
593
|
+
cmd = [
|
|
594
|
+
'yt-dlp', '--dump-json', '--no-download', '--flat-playlist',
|
|
595
|
+
'--playlist-end', str(max(1, int(limit))), url,
|
|
596
|
+
]
|
|
597
|
+
try:
|
|
598
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=False)
|
|
599
|
+
if result.returncode != 0:
|
|
600
|
+
print(f'yt-dlp erro para @{username}: {result.stderr.strip()}')
|
|
601
|
+
return []
|
|
602
|
+
posts: list[dict[str, Any]] = []
|
|
603
|
+
for line in result.stdout.splitlines():
|
|
604
|
+
if not line.strip():
|
|
605
|
+
continue
|
|
606
|
+
try:
|
|
607
|
+
data = json.loads(line)
|
|
608
|
+
except json.JSONDecodeError:
|
|
609
|
+
continue
|
|
610
|
+
post_id = str(data.get('id') or '').strip()
|
|
611
|
+
post_url = str(data.get('webpage_url') or data.get('url') or '').strip()
|
|
612
|
+
shortcode = post_id or (post_url.rstrip('/').split('/')[-1] if post_url else '')
|
|
613
|
+
if not shortcode and not post_id:
|
|
614
|
+
continue
|
|
615
|
+
timestamp = data.get('timestamp') or data.get('release_timestamp') or ''
|
|
616
|
+
thumbnail = str(data.get('thumbnail') or '').strip()
|
|
617
|
+
posts.append({
|
|
618
|
+
'id': post_id or shortcode,
|
|
619
|
+
'shortcode': shortcode,
|
|
620
|
+
'url': post_url or f'https://www.instagram.com/p/{shortcode}/',
|
|
621
|
+
'image_url': thumbnail,
|
|
622
|
+
'display_url': thumbnail,
|
|
623
|
+
'caption': str(data.get('description') or data.get('title') or '').strip(),
|
|
624
|
+
'published_at': timestamp,
|
|
625
|
+
'timestamp': timestamp,
|
|
626
|
+
'is_video': bool(data.get('vcodec') not in (None, 'none')),
|
|
627
|
+
})
|
|
628
|
+
posts = posts[:max(1, int(limit))]
|
|
629
|
+
print(f'yt-dlp sucesso para @{username}: {len(posts)} posts')
|
|
630
|
+
return posts
|
|
631
|
+
except Exception as exc:
|
|
632
|
+
print(f'yt-dlp exceção para @{username}: {exc}')
|
|
633
|
+
return []
|
|
634
|
+
|
|
635
|
+
|
|
588
636
|
def fetch_public_instagram_posts(source: str, limit: int = 10) -> IntegrationResult:
|
|
589
637
|
try:
|
|
590
638
|
reference = normalize_instagram_reference(source)
|
|
591
639
|
except ValueError as exc:
|
|
592
640
|
return IntegrationResult(False, str(exc), {})
|
|
641
|
+
if platform.system() == 'Windows':
|
|
642
|
+
ytdlp_posts = _fetch_posts_with_ytdlp(reference['username'], limit)
|
|
643
|
+
print(f'yt-dlp posts retornados para @{reference["username"]}: {len(ytdlp_posts)}')
|
|
644
|
+
if ytdlp_posts:
|
|
645
|
+
return IntegrationResult(True, f'{len(ytdlp_posts)} posts obtidos via yt-dlp', reference | {'posts': ytdlp_posts})
|
|
593
646
|
api_user = _fetch_web_profile_user(reference['username'])
|
|
594
647
|
if api_user:
|
|
595
648
|
api_posts: list[dict[str, Any]] = []
|
package/package.json
CHANGED