@danhachuel/thunderbolt 0.6.70 → 0.6.72

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 CHANGED
@@ -1168,7 +1168,7 @@ def render_video_generation_settings(
1168
1168
  st.caption("Carregue uma narração pronta; o ficheiro será usado directamente pelo MoneyPrinterTurbo.")
1169
1169
  uploaded_voiceover = st.file_uploader(
1170
1170
  "Ficheiro de narração",
1171
- type=["mp3", "wav", "m4a", "aac", "flac", "ogg"],
1171
+ type=["mp3", "mpeg", "wav", "m4a", "aac", "flac", "ogg"],
1172
1172
  key=f"{prefix}_voiceover_upload",
1173
1173
  )
1174
1174
  if uploaded_voiceover is not None and st.button("Guardar áudio de narração", key=f"{prefix}_voiceover_store", use_container_width=True):
@@ -3522,7 +3522,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3522
3522
  else:
3523
3523
  st.warning("Ainda não existem músicas em storage/music. Escolha Carregar ficheiro ou Criar via Suno API.")
3524
3524
  elif music_source == "Carregar ficheiro":
3525
- uploaded_music = st.file_uploader("Carregar música", type=["mp3", "wav", "m4a", "aac", "flac", "ogg"], key=f"{prefix}_music_upload")
3525
+ uploaded_music = st.file_uploader("Carregar música", type=["mp3", "mpeg", "wav", "m4a", "aac", "flac", "ogg"], key=f"{prefix}_music_upload")
3526
3526
  if uploaded_music and st.button("Guardar música local", key=f"{prefix}_music_store", use_container_width=True):
3527
3527
  try:
3528
3528
  stored_music = store_music_file(uploaded_music.name, uploaded_music.getvalue())
@@ -5105,73 +5105,174 @@ def render_videos():
5105
5105
  st.rerun()
5106
5106
 
5107
5107
 
5108
- def render_music_backlog() -> None:
5109
- """Render the independent audio-only queue; it never reads the video pipeline."""
5110
- st.subheader("Music Backlog")
5111
- st.caption("Fila independente de geração de áudio por Suno AI ou Google Lyria. Não inclui tarefas, worker ou progresso de vídeo.")
5112
- st.caption(f"As músicas são guardadas em `{STORAGE / 'music'}`.")
5108
+ def _music_backlog_records() -> list[dict[str, Any]]:
5109
+ """Combine generated music tasks with audio files imported into storage/music."""
5113
5110
  tasks = list_music_tasks()
5114
- active = [task for task in tasks if str(task.get("state") or "") == "doing"]
5115
- if active:
5116
- st.info(f"Geração musical em execução · {len(active)} tarefa(s) de áudio.")
5117
- if not tasks:
5118
- st.info("Nenhuma música criada.")
5119
- return
5120
- known_states = ["to_do", "doing", "blocked", "done", "failed", "cancelled"]
5121
- extra_states = sorted({str(task.get("state") or "unknown") for task in tasks if str(task.get("state") or "unknown") not in known_states})
5122
- state_filter = st.selectbox("Filtrar por estado", ["Todos", *known_states, *extra_states], key="music_backlog_state_filter")
5111
+ records: list[dict[str, Any]] = []
5112
+ registered_paths: set[str] = set()
5123
5113
  for task in tasks:
5124
- if state_filter != "Todos" and task.get("state") != state_filter:
5114
+ record = dict(task)
5115
+ record["source_type"] = "created"
5116
+ audio_path = Path(str(record.get("audio_path") or "")) if record.get("audio_path") else None
5117
+ if audio_path:
5118
+ registered_paths.add(str(audio_path.resolve()))
5119
+ records.append(record)
5120
+ for music_file in list_music_files():
5121
+ if str(music_file.resolve()) in registered_paths:
5125
5122
  continue
5126
- with st.container(border=True):
5127
- cols = st.columns([2.2, 1, 1, 1.2, 1.8])
5128
- with cols[0]:
5129
- st.write(f"**{task.get('title') or 'Música sem título'}**")
5130
- st.caption(f"Provider: {'Google Lyria' if str(task.get('provider') or '').casefold() == 'lyria' else 'Suno AI'}")
5131
- st.caption(str(task.get('id') or ''))
5132
- music_path = str(task.get("audio_path") or "").strip()
5133
- if music_path and Path(music_path).is_file():
5134
- music_file = Path(music_path)
5135
- st.success("Música pronta; pode continuar para o destino configurado.")
5136
- st.download_button(
5137
- "Descarregar música",
5138
- data=music_file.read_bytes(),
5139
- file_name=music_file.name,
5140
- mime="audio/mpeg",
5141
- key=f"music_backlog_download_{task['id']}",
5142
- use_container_width=True,
5143
- )
5144
- elif music_path:
5145
- st.caption(f"Música registada: {music_path}")
5146
- else:
5147
- st.caption("A música será disponibilizada quando a etapa de geração terminar.")
5148
- with cols[1]:
5149
- st.caption("Tipo")
5150
- st.write("Áudio")
5151
- with cols[2]:
5152
- st.write({"music_generation": "Geração musical", "completed": "Concluída", "failed": "Falha"}.get(str(task.get("stage") or ""), "Na fila"))
5153
- with cols[3]:
5154
- state = str(task.get("state") or "unknown").strip().lower()
5155
- progress = _video_task_progress(task)
5156
- st.caption("Estado")
5157
- st.write(state or "—")
5158
- st.caption(VIDEO_TASK_STATE_LABELS.get(state, state.replace("_", " ").capitalize() or "Desconhecido"))
5159
- st.progress(progress, text=f"{progress}%")
5160
- if task.get("error"):
5161
- st.error(str(task.get("error") or "")[:500])
5162
- with cols[4]:
5163
- state = str(task.get("state") or "")
5123
+ records.append({
5124
+ "id": f"imported_{hashlib.sha1(str(music_file.resolve()).encode('utf-8')).hexdigest()[:12]}",
5125
+ "title": get_display_name("music", music_file, music_file.stem),
5126
+ "provider": "imported",
5127
+ "stage": "imported",
5128
+ "state": "done",
5129
+ "progress": 100,
5130
+ "audio_path": str(music_file),
5131
+ "source_type": "imported",
5132
+ "created_at": datetime.fromtimestamp(music_file.stat().st_mtime, timezone.utc).isoformat(timespec="seconds"),
5133
+ })
5134
+ return records
5135
+
5136
+
5137
+ def _music_lyrics_records() -> list[dict[str, Any]]:
5138
+ """Return persisted music-lyrics documents that still exist on disk."""
5139
+ records: list[dict[str, Any]] = []
5140
+ for document in list_script_documents():
5141
+ if str(document.get("document_type") or "").strip() != "music_lyrics":
5142
+ continue
5143
+ path = Path(str(document.get("path") or ""))
5144
+ if path.is_file():
5145
+ records.append({**document, "path": str(path), "title": get_display_name("music_lyrics", path, str(document.get("title") or path.stem))})
5146
+ return records
5147
+
5148
+
5149
+ def _render_music_file_card(record: dict[str, Any]) -> None:
5150
+ music_path = Path(str(record.get("audio_path") or ""))
5151
+ record_id = str(record.get("id") or music_path.name)
5152
+ title = get_display_name("music", music_path, str(record.get("title") or music_path.stem)) if music_path else str(record.get("title") or "Música sem título")
5153
+ with st.container(border=True):
5154
+ cols = st.columns([2.2, 1, 1, 1.2, 1.8])
5155
+ with cols[0]:
5156
+ st.write(f"**{title}**")
5157
+ source_type = "Música criada" if str(record.get("source_type") or "") == "created" else "Música importada"
5158
+ st.caption(source_type)
5159
+ if str(record.get("provider") or "").casefold() not in {"", "imported"}:
5160
+ provider = "Google Lyria" if str(record.get("provider") or "").casefold() == "lyria" else "Suno AI"
5161
+ st.caption(f"Provider: {provider}")
5162
+ st.caption(record_id)
5163
+ if music_path.is_file():
5164
+ st.download_button(
5165
+ "Descarregar música",
5166
+ data=music_path.read_bytes(),
5167
+ file_name=music_path.name,
5168
+ mime=mimetypes.guess_type(music_path.name)[0] or "audio/mpeg",
5169
+ key=f"music_backlog_download_{record_id}",
5170
+ use_container_width=True,
5171
+ )
5172
+ edit_key = _render_library_name_editor("music", music_path, title)
5173
+ with st.columns([0.2, 0.8])[0]:
5174
+ _render_card_pencil(edit_key)
5175
+ else:
5176
+ st.caption(f"Música registada: {music_path}")
5177
+ with cols[1]:
5178
+ st.caption("Tipo")
5179
+ st.write("Áudio")
5180
+ with cols[2]:
5181
+ stage = str(record.get("stage") or "")
5182
+ st.write({"music_generation": "Geração musical", "completed": "Concluída", "failed": "Falha", "imported": "Importada"}.get(stage, "Na fila"))
5183
+ with cols[3]:
5184
+ state = str(record.get("state") or "unknown").strip().lower()
5185
+ progress = _video_task_progress(record)
5186
+ st.caption("Estado")
5187
+ st.write(state or "—")
5188
+ st.caption(VIDEO_TASK_STATE_LABELS.get(state, state.replace("_", " ").capitalize() or "Desconhecido"))
5189
+ st.progress(progress, text=f"{progress}%")
5190
+ if record.get("error"):
5191
+ st.error(str(record.get("error") or "")[:500])
5192
+ with cols[4]:
5193
+ if str(record.get("source_type") or "") == "created":
5194
+ state = str(record.get("state") or "")
5164
5195
  start_col, stop_col = st.columns(2)
5165
5196
  with start_col:
5166
- if st.button("Start", key=f"music_backlog_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5167
- run_music_task(str(task["id"]), read_json("settings.json", {}))
5197
+ if st.button("Start", key=f"music_backlog_start_{record_id}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5198
+ run_music_task(record_id, read_json("settings.json", {}))
5168
5199
  st.rerun()
5169
5200
  with stop_col:
5170
- if st.button("Stop", key=f"music_backlog_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
5171
- transition_music_task(str(task["id"]), "blocked")
5201
+ if st.button("Stop", key=f"music_backlog_stop_{record_id}", use_container_width=True, disabled=state != "doing"):
5202
+ transition_music_task(record_id, "blocked")
5172
5203
  st.rerun()
5173
5204
 
5174
5205
 
5206
+ def _render_lyrics_card(record: dict[str, Any]) -> None:
5207
+ path = Path(str(record.get("path") or ""))
5208
+ if not path.is_file():
5209
+ return
5210
+ record_id = str(record.get("id") or hashlib.sha1(str(path.resolve()).encode("utf-8")).hexdigest()[:12])
5211
+ title = str(record.get("title") or path.stem)
5212
+ content = read_script_document(record)
5213
+ with st.container(border=True):
5214
+ title_col, download_col, edit_col = st.columns([3.2, 1.3, 0.6])
5215
+ with title_col:
5216
+ st.write(f"**{title}**")
5217
+ st.caption("Lyrics")
5218
+ with download_col:
5219
+ st.download_button("Descarregar lyrics", data=path.read_bytes(), file_name=path.name, mime="text/markdown", key=f"music_lyrics_download_{record_id}", use_container_width=True)
5220
+ with edit_col:
5221
+ edit_key = _render_library_name_editor("music_lyrics", path, title)
5222
+ _render_card_pencil(edit_key)
5223
+ with st.expander("Ver lyrics", expanded=False):
5224
+ st.code(content or path.read_text(encoding="utf-8"), language="markdown")
5225
+
5226
+
5227
+ def render_music_backlog() -> None:
5228
+ """List generated and imported music, plus locally stored lyrics."""
5229
+ st.subheader("Music Backlog")
5230
+ st.caption("Fila independente de geração e importação de áudio por Suno AI ou Google Lyria. Não inclui tarefas, worker ou progresso de vídeo.")
5231
+ st.caption(f"As músicas são guardadas em `{STORAGE / 'music'}`.")
5232
+ music_tab, lyrics_tab = st.tabs(["Músicas", "Lyrics"])
5233
+ with music_tab:
5234
+ with st.form("music_backlog_upload_form", clear_on_submit=True):
5235
+ uploaded_music = st.file_uploader("Adicionar músicas à pasta acima", type=["mp3", "mpeg", "wav", "m4a", "aac", "flac", "ogg"], accept_multiple_files=True, key="music_backlog_upload")
5236
+ upload_music = st.form_submit_button("Guardar músicas", type="primary", use_container_width=True)
5237
+ if upload_music and uploaded_music:
5238
+ for uploaded in uploaded_music:
5239
+ store_music_file(uploaded.name, uploaded.getvalue())
5240
+ st.success(f"{len(uploaded_music)} música(s) guardada(s) em `{STORAGE / 'music'}`.")
5241
+ st.rerun()
5242
+ records = _music_backlog_records()
5243
+ active = [record for record in records if str(record.get("state") or "") == "doing"]
5244
+ if active:
5245
+ st.info(f"Geração musical em execução · {len(active)} tarefa(s) de áudio.")
5246
+ if not records:
5247
+ st.info("Nenhuma música criada ou importada.")
5248
+ else:
5249
+ known_states = ["to_do", "doing", "blocked", "done", "failed", "cancelled"]
5250
+ extra_states = sorted({str(record.get("state") or "unknown") for record in records if str(record.get("state") or "unknown") not in known_states})
5251
+ state_filter = st.selectbox("Filtrar por estado", ["Todos", *known_states, *extra_states], key="music_backlog_state_filter")
5252
+ for record in records:
5253
+ if state_filter != "Todos" and record.get("state") != state_filter:
5254
+ continue
5255
+ _render_music_file_card(record)
5256
+ with lyrics_tab:
5257
+ st.caption(f"As lyrics são guardadas em `{script_storage_path()}` no formato Markdown local do Thunderbolt.")
5258
+ with st.form("music_backlog_lyrics_upload_form", clear_on_submit=True):
5259
+ uploaded_lyrics = st.file_uploader("Adicionar lyrics", type=["md", "txt"], key="music_backlog_lyrics_upload")
5260
+ upload_lyrics = st.form_submit_button("Guardar lyrics", use_container_width=True)
5261
+ if upload_lyrics and uploaded_lyrics:
5262
+ content = uploaded_lyrics.getvalue().decode("utf-8", errors="replace").strip()
5263
+ if content:
5264
+ save_script_document({"title": Path(uploaded_lyrics.name).stem, "document_type": "music_lyrics", "content": content})
5265
+ st.success("Lyrics guardadas.")
5266
+ st.rerun()
5267
+ else:
5268
+ st.warning("O ficheiro de lyrics está vazio.")
5269
+ lyrics = _music_lyrics_records()
5270
+ if not lyrics:
5271
+ st.info("Nenhuma lyrics guardada.")
5272
+ for record in lyrics:
5273
+ _render_lyrics_card(record)
5274
+
5275
+
5175
5276
  def _thumbnail_editor_context(record: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
5176
5277
  """Resolve the persisted task channel/Blueprint without requiring either one to remain registered."""
5177
5278
  channel_id = str(record.get("channel_id") or "").strip()
@@ -13,7 +13,7 @@ import requests
13
13
  from . import storage
14
14
  from .notifications import record_notification
15
15
 
16
- MUSIC_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
16
+ MUSIC_EXTENSIONS = {".mp3", ".mpeg", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
17
17
  VOICEOVER_EXTENSIONS = set(MUSIC_EXTENSIONS)
18
18
 
19
19
 
@@ -775,7 +775,7 @@ def _mpt_percent(value: Any, fallback: float = 1.0) -> str:
775
775
 
776
776
  def _valid_audio_artifact(value: Any) -> Path | None:
777
777
  path = _valid_artifact_path(value)
778
- if path is None or path.suffix.lower() not in {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}:
778
+ if path is None or path.suffix.lower() not in {'.mp3', '.mpeg', '.wav', '.m4a', '.aac', '.flac', '.ogg'}:
779
779
  return None
780
780
  return path
781
781
 
@@ -16,7 +16,7 @@ from .metadata_cleaner import VIDEO_EXTENSIONS as METADATA_VIDEO_EXTENSIONS
16
16
  from .metadata_cleaner import _resolve_ffmpeg
17
17
 
18
18
  VIDEO_EXTENSIONS = {f".{extension.lower().lstrip('.')}" for extension in METADATA_VIDEO_EXTENSIONS}
19
- AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
19
+ AUDIO_EXTENSIONS = {".mp3", ".mpeg", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
20
20
 
21
21
 
22
22
  class PythonEditorError(RuntimeError):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.70",
3
+ "version": "0.6.72",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",