@danhachuel/thunderbolt 0.3.70 → 0.3.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/MANUAL-INSTALACAO.md +19 -10
- package/README.md +23 -11
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/app/influencers_ui.py +183 -8
- package/app/main.py +206 -10
- package/hermes_ui/creative_generation.py +39 -0
- package/hermes_ui/influencers.py +19 -0
- package/hermes_ui/media_generation.py +335 -0
- package/integrations/bilibili_upload.py +259 -0
- package/integrations/distrokid_upload.py +227 -0
- package/package.json +1 -1
- package/requirements.txt +2 -0
|
@@ -9,6 +9,12 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
import base64
|
|
11
11
|
import binascii
|
|
12
|
+
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
12
18
|
import time
|
|
13
19
|
from pathlib import Path
|
|
14
20
|
from typing import Any, Mapping
|
|
@@ -544,3 +550,332 @@ def generate_video_from_pool(
|
|
|
544
550
|
if not _is_retryable_media_error(exc):
|
|
545
551
|
raise
|
|
546
552
|
raise MediaGenerationError("Todos os providers do pool de vídeo falharam: " + " | ".join(errors))
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
KIE_FILE_UPLOAD_ENDPOINT = "https://kieai.redpandaai.co/api/file-stream-upload"
|
|
556
|
+
KIE_MOTION_MODEL = "kling-2.6/motion-control"
|
|
557
|
+
KIE_MOTION_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
|
558
|
+
KIE_MOTION_VIDEO_MAX_BYTES = 100 * 1024 * 1024
|
|
559
|
+
KIE_MOTION_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
|
560
|
+
KIE_MOTION_VIDEO_EXTENSIONS = {".mp4", ".mov"}
|
|
561
|
+
KIE_VEO_MODELS = {"veo3", "veo3_fast", "veo3_lite"}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _require_kie_card(card: Mapping[str, Any]) -> dict[str, Any]:
|
|
565
|
+
current = dict(card)
|
|
566
|
+
if str(current.get("provider") or "").strip().lower() != "kie_ai":
|
|
567
|
+
raise MediaGenerationError("Este workflow requer um cartão KIE AI activo no pool de vídeo.")
|
|
568
|
+
if not _api_key(current):
|
|
569
|
+
raise MediaGenerationError("Configure a API key do KIE AI em Configuração API > API Keys > Imagem e Video IA.")
|
|
570
|
+
return current
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _kie_json_error(payload: Any, *, fallback: str) -> ProviderCallError | None:
|
|
574
|
+
if not isinstance(payload, Mapping):
|
|
575
|
+
return ProviderCallError(fallback, category="payload", retryable=False)
|
|
576
|
+
try:
|
|
577
|
+
code = int(payload.get("code", 200))
|
|
578
|
+
except (TypeError, ValueError):
|
|
579
|
+
code = 200
|
|
580
|
+
if code == 200:
|
|
581
|
+
return None
|
|
582
|
+
category = "quota" if code in {402, 429, 433} else "credential" if code == 401 else "endpoint_or_model" if code in {404, 455, 505} else "transient" if code >= 500 else "payload"
|
|
583
|
+
return ProviderCallError(str(payload.get("msg") or fallback)[:500], status_code=code, category=category, retryable=category in {"quota", "transient"})
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def validate_motion_control_file(path: str | Path, *, kind: str) -> dict[str, Any]:
|
|
587
|
+
"""Validate KIE Motion Control local input limits before uploading it."""
|
|
588
|
+
candidate = Path(path).expanduser()
|
|
589
|
+
if not candidate.is_file():
|
|
590
|
+
raise MediaGenerationError(f"O ficheiro de {kind} não está disponível.")
|
|
591
|
+
suffix = candidate.suffix.lower()
|
|
592
|
+
if kind == "imagem":
|
|
593
|
+
allowed, maximum = KIE_MOTION_IMAGE_EXTENSIONS, KIE_MOTION_IMAGE_MAX_BYTES
|
|
594
|
+
elif kind == "vídeo":
|
|
595
|
+
allowed, maximum = KIE_MOTION_VIDEO_EXTENSIONS, KIE_MOTION_VIDEO_MAX_BYTES
|
|
596
|
+
else:
|
|
597
|
+
raise MediaGenerationError("Tipo de input Motion Control inválido.")
|
|
598
|
+
if suffix not in allowed:
|
|
599
|
+
raise MediaGenerationError(f"O {kind} Motion Control deve estar em {', '.join(sorted(allowed))}.")
|
|
600
|
+
size = candidate.stat().st_size
|
|
601
|
+
if size <= 0:
|
|
602
|
+
raise MediaGenerationError(f"O ficheiro de {kind} está vazio.")
|
|
603
|
+
if size > maximum:
|
|
604
|
+
raise MediaGenerationError(f"O ficheiro de {kind} excede o limite KIE de {maximum // (1024 * 1024)} MB.")
|
|
605
|
+
duration = None
|
|
606
|
+
if kind == "vídeo":
|
|
607
|
+
ffprobe = shutil.which("ffprobe")
|
|
608
|
+
if not ffprobe:
|
|
609
|
+
raise MediaGenerationError("Não foi possível verificar a duração do vídeo Motion Control: instale FFmpeg/ffprobe e tente novamente.")
|
|
610
|
+
try:
|
|
611
|
+
probe = subprocess.run(
|
|
612
|
+
[ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(candidate)],
|
|
613
|
+
capture_output=True,
|
|
614
|
+
text=True,
|
|
615
|
+
timeout=30,
|
|
616
|
+
check=False,
|
|
617
|
+
)
|
|
618
|
+
duration = float(probe.stdout.strip())
|
|
619
|
+
except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
|
620
|
+
raise MediaGenerationError("Não foi possível verificar a duração do vídeo Motion Control com ffprobe.") from exc
|
|
621
|
+
if duration < 3 or duration > 30:
|
|
622
|
+
raise MediaGenerationError(f"O vídeo Motion Control deve ter entre 3 e 30 segundos; o ficheiro tem {duration:.1f}s.")
|
|
623
|
+
return {"path": str(candidate), "name": candidate.name, "size_bytes": size, "extension": suffix, "duration_seconds": duration}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def upload_kie_file(path: str | Path, card: Mapping[str, Any], *, upload_path: str = "thunderbolt/influencers") -> str:
|
|
627
|
+
"""Upload one local file to KIE's temporary public file store using the selected card."""
|
|
628
|
+
current = _require_kie_card(card)
|
|
629
|
+
candidate = Path(path).expanduser()
|
|
630
|
+
if not candidate.is_file():
|
|
631
|
+
raise MediaGenerationError("O ficheiro seleccionado não está disponível para upload KIE.")
|
|
632
|
+
mime = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
|
|
633
|
+
try:
|
|
634
|
+
with candidate.open("rb") as handle:
|
|
635
|
+
response = requests.post(
|
|
636
|
+
KIE_FILE_UPLOAD_ENDPOINT,
|
|
637
|
+
headers={"Authorization": f"Bearer {_api_key(current)}"},
|
|
638
|
+
files={"file": (candidate.name, handle, mime)},
|
|
639
|
+
data={"uploadPath": upload_path, "fileName": candidate.name},
|
|
640
|
+
timeout=300,
|
|
641
|
+
)
|
|
642
|
+
except requests.RequestException as exc:
|
|
643
|
+
raise MediaGenerationError(f"Falha no upload temporário do ficheiro para KIE: {str(exc)[:220]}") from exc
|
|
644
|
+
if response.status_code >= 400:
|
|
645
|
+
raise MediaGenerationError(f"O upload temporário KIE devolveu HTTP {response.status_code}.")
|
|
646
|
+
try:
|
|
647
|
+
payload = response.json()
|
|
648
|
+
except ValueError as exc:
|
|
649
|
+
raise MediaGenerationError("O upload temporário KIE devolveu uma resposta inválida.") from exc
|
|
650
|
+
error = _kie_json_error(payload, fallback="O upload temporário KIE falhou.")
|
|
651
|
+
if error:
|
|
652
|
+
raise MediaGenerationError(str(error))
|
|
653
|
+
data = payload.get("data") if isinstance(payload, Mapping) else None
|
|
654
|
+
url = str((data or {}).get("downloadUrl") or (data or {}).get("fileUrl") or payload.get("downloadUrl") or payload.get("fileUrl") or "").strip() if isinstance(payload, Mapping) else ""
|
|
655
|
+
if not url.startswith(("http://", "https://")):
|
|
656
|
+
raise MediaGenerationError("O upload KIE terminou sem devolver um URL público temporário.")
|
|
657
|
+
return url
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _download_video_url(url: str, destination: Path, *, card: Mapping[str, Any] | None = None) -> Path:
|
|
661
|
+
try:
|
|
662
|
+
response = requests.get(url, headers=_headers(card or {}) if card else {}, timeout=300)
|
|
663
|
+
response.raise_for_status()
|
|
664
|
+
except requests.RequestException as exc:
|
|
665
|
+
raise MediaGenerationError(f"Não foi possível descarregar o vídeo gerado: {str(exc)[:220]}") from exc
|
|
666
|
+
if not response.content:
|
|
667
|
+
raise MediaGenerationError("O provider devolveu um vídeo vazio.")
|
|
668
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
669
|
+
destination.write_bytes(response.content)
|
|
670
|
+
return destination
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _kie_result_urls(value: Any) -> list[str]:
|
|
674
|
+
if isinstance(value, str):
|
|
675
|
+
raw = value.strip()
|
|
676
|
+
try:
|
|
677
|
+
value = json.loads(raw)
|
|
678
|
+
except json.JSONDecodeError:
|
|
679
|
+
value = [raw] if raw.startswith(("http://", "https://")) else []
|
|
680
|
+
if isinstance(value, Mapping):
|
|
681
|
+
value = value.get("resultUrls") or value.get("result_urls") or value.get("urls") or []
|
|
682
|
+
if not isinstance(value, list):
|
|
683
|
+
return []
|
|
684
|
+
return [str(item).strip() for item in value if str(item).strip().startswith(("http://", "https://"))]
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _poll_kie_task(card: Mapping[str, Any], task_id: str, *, endpoint: str, attempts: int = 40, interval_seconds: float = 5.0, veo: bool = False) -> list[str]:
|
|
688
|
+
base = _base_url(card) or "https://api.kie.ai/api/v1"
|
|
689
|
+
url = f"{base}{endpoint}"
|
|
690
|
+
for index in range(max(1, attempts)):
|
|
691
|
+
try:
|
|
692
|
+
response = requests.get(url, headers=_headers(card), params={"taskId": task_id}, timeout=60)
|
|
693
|
+
if response.status_code >= 400:
|
|
694
|
+
category = "quota" if response.status_code == 429 else "transient" if response.status_code >= 500 else "endpoint_or_model"
|
|
695
|
+
raise ProviderCallError(f"Consulta KIE devolveu HTTP {response.status_code}.", status_code=response.status_code, category=category, retryable=category in {"quota", "transient"})
|
|
696
|
+
payload = response.json()
|
|
697
|
+
except requests.RequestException as exc:
|
|
698
|
+
raise ProviderCallError(f"Falha ao consultar a tarefa KIE: {str(exc)[:220]}", category="transient", retryable=True) from exc
|
|
699
|
+
error = _kie_json_error(payload, fallback="A consulta KIE devolveu um erro.")
|
|
700
|
+
if error:
|
|
701
|
+
raise error
|
|
702
|
+
data = payload.get("data") if isinstance(payload, Mapping) and isinstance(payload.get("data"), Mapping) else {}
|
|
703
|
+
if veo:
|
|
704
|
+
flag = data.get("successFlag")
|
|
705
|
+
if str(flag) in {"2", "3"}:
|
|
706
|
+
raise ProviderCallError(str(data.get("errorMessage") or payload.get("msg") or "A tarefa VEO falhou."), category="provider", retryable=False)
|
|
707
|
+
response_data = data.get("response") if isinstance(data.get("response"), Mapping) else {}
|
|
708
|
+
urls = _kie_result_urls(response_data.get("resultUrls") or data.get("resultUrls"))
|
|
709
|
+
if str(flag) == "1" and urls:
|
|
710
|
+
return urls
|
|
711
|
+
else:
|
|
712
|
+
state = str(data.get("state") or payload.get("state") or "").lower()
|
|
713
|
+
if state in {"fail", "failed", "error", "cancelled", "canceled"}:
|
|
714
|
+
raise ProviderCallError(str(data.get("failMsg") or payload.get("msg") or "A tarefa KIE falhou."), category="provider", retryable=False)
|
|
715
|
+
urls = _kie_result_urls(data.get("resultJson"))
|
|
716
|
+
if state == "success" and urls:
|
|
717
|
+
return urls
|
|
718
|
+
if index + 1 < attempts:
|
|
719
|
+
time.sleep(max(0.2, interval_seconds))
|
|
720
|
+
raise ProviderCallError("A tarefa KIE não concluiu dentro do limite de polling local.", category="transient", retryable=True)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def generate_motion_control_video(
|
|
724
|
+
settings: Mapping[str, Any],
|
|
725
|
+
card: Mapping[str, Any],
|
|
726
|
+
*,
|
|
727
|
+
image_url: str,
|
|
728
|
+
video_url: str,
|
|
729
|
+
prompt: str = "",
|
|
730
|
+
output_path: Path | None = None,
|
|
731
|
+
) -> tuple[Path, str]:
|
|
732
|
+
"""Create a Kling 2.6 Motion Control video and download it locally."""
|
|
733
|
+
current = _require_kie_card(card)
|
|
734
|
+
if not image_url.startswith(("http://", "https://")) or not video_url.startswith(("http://", "https://")):
|
|
735
|
+
raise MediaGenerationError("Motion Control requer URLs KIE acessíveis para a imagem e o vídeo enviados.")
|
|
736
|
+
clean_prompt = str(prompt or "").strip()
|
|
737
|
+
if len(clean_prompt) > 2500:
|
|
738
|
+
raise MediaGenerationError("O prompt Motion Control não pode exceder 2500 caracteres.")
|
|
739
|
+
base = _base_url(current) or "https://api.kie.ai/api/v1"
|
|
740
|
+
body: dict[str, Any] = {
|
|
741
|
+
"model": KIE_MOTION_MODEL,
|
|
742
|
+
"input": {
|
|
743
|
+
"prompt": clean_prompt or "Preserve a identidade visual da imagem de referência e aplique os movimentos do vídeo de forma natural, estável e fisicamente plausível.",
|
|
744
|
+
"input_urls": [image_url],
|
|
745
|
+
"video_urls": [video_url],
|
|
746
|
+
"character_orientation": "video",
|
|
747
|
+
"mode": "720p",
|
|
748
|
+
},
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
def request(_: dict[str, Any]) -> Any:
|
|
752
|
+
response = requests.post(f"{base}/jobs/createTask", headers=_headers(current), json=body, timeout=180)
|
|
753
|
+
if response.status_code < 400:
|
|
754
|
+
try:
|
|
755
|
+
payload = response.json()
|
|
756
|
+
except ValueError:
|
|
757
|
+
payload = {}
|
|
758
|
+
error = _kie_json_error(payload, fallback="A criação Motion Control KIE falhou.")
|
|
759
|
+
if error:
|
|
760
|
+
raise error
|
|
761
|
+
return response
|
|
762
|
+
|
|
763
|
+
try:
|
|
764
|
+
routed = route_json_request(settings, pool=POOL_VIDEO, cards=[current], request=request)
|
|
765
|
+
data = routed.payload.get("data") if isinstance(routed.payload.get("data"), Mapping) else {}
|
|
766
|
+
task_id = str(data.get("taskId") or routed.payload.get("taskId") or "").strip()
|
|
767
|
+
if not task_id:
|
|
768
|
+
raise MediaGenerationError("KIE aceitou Motion Control mas não devolveu taskId.")
|
|
769
|
+
urls = _poll_kie_task(routed.card, task_id, endpoint="/jobs/recordInfo", veo=False)
|
|
770
|
+
except (ProviderRoutingError, ProviderCallError) as exc:
|
|
771
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
772
|
+
destination = output_path or (STORAGE / "influencer_workflows" / f"motion-control-{task_id}.mp4")
|
|
773
|
+
return _download_video_url(urls[0], destination, card=routed.card), task_id
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def generate_ugc_segment(
|
|
777
|
+
settings: Mapping[str, Any],
|
|
778
|
+
card: Mapping[str, Any],
|
|
779
|
+
*,
|
|
780
|
+
image_url: str,
|
|
781
|
+
prompt: str,
|
|
782
|
+
output_path: Path,
|
|
783
|
+
duration: int = 8,
|
|
784
|
+
) -> tuple[Path, str]:
|
|
785
|
+
"""Create one VEO3.1 image-to-video segment through the official KIE endpoint."""
|
|
786
|
+
current = _require_kie_card(card)
|
|
787
|
+
if not image_url.startswith(("http://", "https://")):
|
|
788
|
+
raise MediaGenerationError("UGC Products requer um URL KIE acessível para a imagem do produto.")
|
|
789
|
+
model = _model(current).lower() or "veo3_fast"
|
|
790
|
+
if model not in KIE_VEO_MODELS:
|
|
791
|
+
model = "veo3_fast"
|
|
792
|
+
if duration not in {4, 6, 8}:
|
|
793
|
+
raise MediaGenerationError("A duração de um segmento VEO3 deve ser 4, 6 ou 8 segundos.")
|
|
794
|
+
base = _base_url(current) or "https://api.kie.ai/api/v1"
|
|
795
|
+
body = {
|
|
796
|
+
"prompt": str(prompt or "").strip(),
|
|
797
|
+
"imageUrls": [image_url],
|
|
798
|
+
"model": model,
|
|
799
|
+
"aspect_ratio": "16:9",
|
|
800
|
+
"resolution": "720p",
|
|
801
|
+
"duration": duration,
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
def request(_: dict[str, Any]) -> Any:
|
|
805
|
+
response = requests.post(f"{base}/veo/generate", headers=_headers(current), json=body, timeout=180)
|
|
806
|
+
if response.status_code < 400:
|
|
807
|
+
try:
|
|
808
|
+
payload = response.json()
|
|
809
|
+
except ValueError:
|
|
810
|
+
payload = {}
|
|
811
|
+
error = _kie_json_error(payload, fallback="A criação de segmento VEO KIE falhou.")
|
|
812
|
+
if error:
|
|
813
|
+
raise error
|
|
814
|
+
return response
|
|
815
|
+
|
|
816
|
+
try:
|
|
817
|
+
routed = route_json_request(settings, pool=POOL_VIDEO, cards=[current], request=request)
|
|
818
|
+
data = routed.payload.get("data") if isinstance(routed.payload.get("data"), Mapping) else {}
|
|
819
|
+
task_id = str(data.get("taskId") or routed.payload.get("taskId") or "").strip()
|
|
820
|
+
if not task_id:
|
|
821
|
+
raise MediaGenerationError("KIE aceitou VEO3 mas não devolveu taskId.")
|
|
822
|
+
urls = _poll_kie_task(routed.card, task_id, endpoint="/veo/record-info", veo=True)
|
|
823
|
+
except (ProviderRoutingError, ProviderCallError) as exc:
|
|
824
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
825
|
+
return _download_video_url(urls[0], output_path, card=routed.card), task_id
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def concatenate_video_files(paths: list[Path], output_path: Path) -> Path:
|
|
829
|
+
"""Join generated clips locally with FFmpeg, without uploading the result anywhere."""
|
|
830
|
+
valid = [Path(path) for path in paths if Path(path).is_file()]
|
|
831
|
+
if not valid:
|
|
832
|
+
raise MediaGenerationError("Não existem segmentos locais para concatenar.")
|
|
833
|
+
if len(valid) == 1:
|
|
834
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
835
|
+
if valid[0] != output_path:
|
|
836
|
+
output_path.write_bytes(valid[0].read_bytes())
|
|
837
|
+
return output_path
|
|
838
|
+
try:
|
|
839
|
+
from .metadata_cleaner import _resolve_ffmpeg
|
|
840
|
+
|
|
841
|
+
ffmpeg = _resolve_ffmpeg()
|
|
842
|
+
except Exception as exc:
|
|
843
|
+
raise MediaGenerationError(str(exc)) from exc
|
|
844
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
845
|
+
descriptor, manifest_name = tempfile.mkstemp(prefix="ugc-concat-", suffix=".txt", dir=str(output_path.parent))
|
|
846
|
+
os.close(descriptor)
|
|
847
|
+
manifest = Path(manifest_name)
|
|
848
|
+
try:
|
|
849
|
+
manifest.write_text("\n".join(f"file '{str(path).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'" for path in valid) + "\n", encoding="utf-8")
|
|
850
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
851
|
+
command = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", str(manifest), "-c", "copy", str(output_path)]
|
|
852
|
+
completed = subprocess.run(command, capture_output=True, text=True, timeout=600, check=False)
|
|
853
|
+
if completed.returncode != 0:
|
|
854
|
+
raise MediaGenerationError(f"FFmpeg não conseguiu juntar os segmentos UGC: {completed.stderr[-500:]}")
|
|
855
|
+
except OSError as exc:
|
|
856
|
+
raise MediaGenerationError(f"Não foi possível executar FFmpeg para juntar os segmentos UGC: {exc}") from exc
|
|
857
|
+
finally:
|
|
858
|
+
manifest.unlink(missing_ok=True)
|
|
859
|
+
return output_path
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def generate_ugc_product_video(
|
|
863
|
+
settings: Mapping[str, Any],
|
|
864
|
+
card: Mapping[str, Any],
|
|
865
|
+
*,
|
|
866
|
+
image_url: str,
|
|
867
|
+
prompts: list[str],
|
|
868
|
+
output_path: Path | None = None,
|
|
869
|
+
) -> tuple[Path, list[str]]:
|
|
870
|
+
"""Generate two 8-second KIE VEO3 clips and concatenate them locally."""
|
|
871
|
+
clean_prompts = [str(item or "").strip() for item in prompts if str(item or "").strip()]
|
|
872
|
+
if len(clean_prompts) != 2:
|
|
873
|
+
raise MediaGenerationError("UGC Products requer exactamente dois prompts de segmento.")
|
|
874
|
+
ensure_storage()
|
|
875
|
+
destination = output_path or (STORAGE / "influencer_workflows" / f"ugc-products-{abs(hash((image_url, *clean_prompts))) & 0xffffffffffffffff:x}.mp4")
|
|
876
|
+
segment_paths = [destination.with_name(f"{destination.stem}-segment-{index + 1}.mp4") for index in range(2)]
|
|
877
|
+
task_ids: list[str] = []
|
|
878
|
+
for prompt, segment_path in zip(clean_prompts, segment_paths):
|
|
879
|
+
_, task_id = generate_ugc_segment(settings, card, image_url=image_url, prompt=prompt, output_path=segment_path, duration=8)
|
|
880
|
+
task_ids.append(task_id)
|
|
881
|
+
return concatenate_video_files(segment_paths, destination), task_ids
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Upload de vídeo para Bilibili usando bilibili-api-python.
|
|
2
|
+
|
|
3
|
+
A biblioteca é opcional no import para que o Thunderbolt continue a arrancar sem
|
|
4
|
+
credenciais Bilibili. As operações do SDK são assíncronas; este adapter expõe uma
|
|
5
|
+
interface síncrona adequada à UI Streamlit e nunca devolve os cookies nos dados.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Awaitable
|
|
15
|
+
|
|
16
|
+
from .platforms import IntegrationResult
|
|
17
|
+
|
|
18
|
+
BILIBILI_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm"}
|
|
19
|
+
BILIBILI_MAX_TITLE = 80
|
|
20
|
+
BILIBILI_MAX_DESCRIPTION = 2000
|
|
21
|
+
BILIBILI_MAX_TAGS = 10
|
|
22
|
+
BILIBILI_DEFAULT_TID = 130
|
|
23
|
+
BILIBILI_API_VERSION = "17.4.2"
|
|
24
|
+
_SENSITIVE_FIELDS = {"sessdata", "bili_jct", "buvid3", "buvid4", "dedeuserid", "ac_time_value", "proxy"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _safe_card(card: dict[str, Any], index: int) -> dict[str, Any]:
|
|
28
|
+
return {
|
|
29
|
+
"id": str(card.get("id") or f"bilibili-api-{index + 1}").strip(),
|
|
30
|
+
"label": str(card.get("label") or card.get("name") or f"Conta Bilibili {index + 1}").strip(),
|
|
31
|
+
"active": bool(card.get("active", True)),
|
|
32
|
+
"sessdata": str(card.get("sessdata") or "").strip(),
|
|
33
|
+
"bili_jct": str(card.get("bili_jct") or "").strip(),
|
|
34
|
+
"buvid3": str(card.get("buvid3") or "").strip(),
|
|
35
|
+
"buvid4": str(card.get("buvid4") or "").strip(),
|
|
36
|
+
"dedeuserid": str(card.get("dedeuserid") or "").strip(),
|
|
37
|
+
"ac_time_value": str(card.get("ac_time_value") or "").strip(),
|
|
38
|
+
"proxy": str(card.get("proxy") or "").strip(),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalise_bilibili_api_cards(settings: dict[str, Any] | None) -> tuple[list[dict[str, Any]], bool]:
|
|
43
|
+
"""Normalise multi-account cards and migrate one legacy settings record."""
|
|
44
|
+
settings = settings or {}
|
|
45
|
+
raw = settings.get("bilibili_api_cards")
|
|
46
|
+
cards: list[dict[str, Any]] = []
|
|
47
|
+
changed = not isinstance(raw, list)
|
|
48
|
+
if isinstance(raw, list):
|
|
49
|
+
for index, item in enumerate(raw):
|
|
50
|
+
if isinstance(item, dict):
|
|
51
|
+
card = _safe_card(item, index)
|
|
52
|
+
cards.append(card)
|
|
53
|
+
if any(str(item.get(key) or "").strip() != str(card.get(key) or "").strip() for key in card if key not in {"active"}):
|
|
54
|
+
changed = True
|
|
55
|
+
else:
|
|
56
|
+
changed = True
|
|
57
|
+
legacy_keys = ("bilibili_sessdata", "bilibili_bili_jct", "bilibili_buvid3", "bilibili_buvid4", "bilibili_dedeuserid", "bilibili_ac_time_value", "bilibili_proxy")
|
|
58
|
+
legacy_values = {key.removeprefix("bilibili_"): str(settings.get(key) or "").strip() for key in legacy_keys}
|
|
59
|
+
if not cards and any(legacy_values.values()):
|
|
60
|
+
cards.append(_safe_card({"id": "bilibili-api-1", "label": "Conta Bilibili 1", **legacy_values}, 0))
|
|
61
|
+
changed = True
|
|
62
|
+
return cards, changed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _redacted_card(card: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
return {key: ("***" if key in _SENSITIVE_FIELDS and value else value) for key, value in card.items() if key not in _SENSITIVE_FIELDS or not value}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _safe_card_payload(card: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
return {key: value for key, value in _safe_card(card, 0).items() if key != "id"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _import_sdk() -> tuple[Any, Any]:
|
|
74
|
+
try:
|
|
75
|
+
from bilibili_api import Credential, video_uploader
|
|
76
|
+
except ImportError as exc:
|
|
77
|
+
raise RuntimeError("A dependência bilibili-api-python não está instalada. Instale/actualize o Thunderbolt e tente novamente.") from exc
|
|
78
|
+
return Credential, video_uploader
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run_async(awaitable: Awaitable[Any]) -> Any:
|
|
82
|
+
"""Run one SDK coroutine from Streamlit, including when a loop already exists."""
|
|
83
|
+
try:
|
|
84
|
+
asyncio.get_running_loop()
|
|
85
|
+
except RuntimeError:
|
|
86
|
+
return asyncio.run(awaitable)
|
|
87
|
+
result: list[Any] = []
|
|
88
|
+
error: list[BaseException] = []
|
|
89
|
+
|
|
90
|
+
def runner() -> None:
|
|
91
|
+
try:
|
|
92
|
+
result.append(asyncio.run(awaitable))
|
|
93
|
+
except BaseException as exc: # preserve SDK exception for UI attribution
|
|
94
|
+
error.append(exc)
|
|
95
|
+
|
|
96
|
+
thread = threading.Thread(target=runner, name="thunderbolt-bilibili-sdk", daemon=True)
|
|
97
|
+
thread.start()
|
|
98
|
+
thread.join()
|
|
99
|
+
if error:
|
|
100
|
+
raise error[0]
|
|
101
|
+
return result[0] if result else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _credential(card: dict[str, Any], Credential: Any) -> Any:
|
|
105
|
+
values = _safe_card_payload(card)
|
|
106
|
+
values.pop("label", None)
|
|
107
|
+
values.pop("active", None)
|
|
108
|
+
values.pop("proxy", None)
|
|
109
|
+
return Credential(proxy=str(card.get("proxy") or "").strip() or None, **values)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _derive_cover(video_path: Path, directory: Path) -> Path:
|
|
113
|
+
try:
|
|
114
|
+
from imageio_ffmpeg import get_ffmpeg_exe
|
|
115
|
+
executable = get_ffmpeg_exe()
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
raise RuntimeError("Não foi possível localizar FFmpeg para criar a capa automática do vídeo Bilibili.") from exc
|
|
118
|
+
target = directory / "bilibili-cover.jpg"
|
|
119
|
+
completed = subprocess.run(
|
|
120
|
+
[executable, "-y", "-hide_banner", "-loglevel", "error", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(target)],
|
|
121
|
+
capture_output=True, text=True, timeout=90, check=False,
|
|
122
|
+
)
|
|
123
|
+
if completed.returncode != 0 or not target.is_file():
|
|
124
|
+
raise RuntimeError("FFmpeg não conseguiu criar uma capa a partir do vídeo Bilibili.")
|
|
125
|
+
return target
|
|
126
|
+
|
|
127
|
+
def _normalise_tags(tags: str | list[str] | tuple[str, ...] | None) -> list[str]:
|
|
128
|
+
values = tags if isinstance(tags, (list, tuple)) else str(tags or "").replace("\n", ",").split(",")
|
|
129
|
+
output: list[str] = []
|
|
130
|
+
seen: set[str] = set()
|
|
131
|
+
for value in values:
|
|
132
|
+
clean = str(value or "").strip()
|
|
133
|
+
if clean and clean not in seen:
|
|
134
|
+
output.append(clean)
|
|
135
|
+
seen.add(clean)
|
|
136
|
+
return output
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class BilibiliApiAdapter:
|
|
140
|
+
"""Synchronous wrapper around bilibili-api-python's asynchronous video uploader."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, card: dict[str, Any] | None = None, settings: dict[str, Any] | None = None):
|
|
143
|
+
self.card = _safe_card(card or {}, 0)
|
|
144
|
+
self.settings = settings or {}
|
|
145
|
+
|
|
146
|
+
def status(self) -> IntegrationResult:
|
|
147
|
+
missing = [field for field in ("sessdata", "bili_jct", "buvid3") if not str(self.card.get(field) or "").strip()]
|
|
148
|
+
if missing:
|
|
149
|
+
return IntegrationResult(False, "Conta Bilibili incompleta: configure SESSDATA, bili_jct e BUVID3.", {"missing_fields": missing, "account": self.card.get("label", "Conta Bilibili")})
|
|
150
|
+
try:
|
|
151
|
+
_import_sdk()
|
|
152
|
+
except RuntimeError as exc:
|
|
153
|
+
return IntegrationResult(False, str(exc), {"account": self.card.get("label", "Conta Bilibili")})
|
|
154
|
+
return IntegrationResult(True, f"Conta Bilibili pronta: {self.card.get('label', 'Conta Bilibili')}.", {"account": self.card.get("label", "Conta Bilibili"), "sdk": BILIBILI_API_VERSION})
|
|
155
|
+
|
|
156
|
+
def test_connection(self) -> IntegrationResult:
|
|
157
|
+
status = self.status()
|
|
158
|
+
if not status.ok:
|
|
159
|
+
return status
|
|
160
|
+
try:
|
|
161
|
+
Credential, _ = _import_sdk()
|
|
162
|
+
credential = _credential(self.card, Credential)
|
|
163
|
+
valid = _run_async(credential.check_valid())
|
|
164
|
+
if not bool(valid):
|
|
165
|
+
return IntegrationResult(False, "A sessão Bilibili foi rejeitada ou expirou. Actualize SESSDATA, bili_jct e BUVID3.", {"account": self.card.get("label", "Conta Bilibili"), "valid": False})
|
|
166
|
+
return IntegrationResult(True, f"Chamada Bilibili concluída para {self.card.get('label', 'Conta Bilibili')}.", {"account": self.card.get("label", "Conta Bilibili"), "valid": True})
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
return IntegrationResult(False, f"A chamada Bilibili falhou: {exc}", {"account": self.card.get("label", "Conta Bilibili"), "api": "bilibili-api-python"})
|
|
169
|
+
|
|
170
|
+
def upload_video(
|
|
171
|
+
self,
|
|
172
|
+
video_path: str | Path,
|
|
173
|
+
*,
|
|
174
|
+
title: str,
|
|
175
|
+
description: str = "",
|
|
176
|
+
tags: str | list[str] | tuple[str, ...] | None = None,
|
|
177
|
+
tid: int = BILIBILI_DEFAULT_TID,
|
|
178
|
+
cover_path: str | Path | None = None,
|
|
179
|
+
original: bool = True,
|
|
180
|
+
dynamic: str = "",
|
|
181
|
+
) -> IntegrationResult:
|
|
182
|
+
status = self.status()
|
|
183
|
+
if not status.ok:
|
|
184
|
+
return status
|
|
185
|
+
path = Path(video_path).expanduser()
|
|
186
|
+
if not path.is_file():
|
|
187
|
+
return IntegrationResult(False, f"Vídeo Bilibili não encontrado: {path}", {"api": "bilibili-api-python"})
|
|
188
|
+
if path.suffix.lower() not in BILIBILI_VIDEO_EXTENSIONS:
|
|
189
|
+
return IntegrationResult(False, f"Formato Bilibili não suportado: {path.suffix}. Use MP4, MOV, MKV ou WEBM.", {"api": "bilibili-api-python"})
|
|
190
|
+
clean_title = str(title or "Vídeo Thunderbolt").strip()
|
|
191
|
+
clean_description = str(description or "").strip()
|
|
192
|
+
clean_tags = _normalise_tags(tags)
|
|
193
|
+
if not clean_title or len(clean_title) > BILIBILI_MAX_TITLE:
|
|
194
|
+
return IntegrationResult(False, "O título Bilibili é obrigatório e deve ter no máximo 80 caracteres.", {"api": "bilibili-api-python"})
|
|
195
|
+
if len(clean_description) > BILIBILI_MAX_DESCRIPTION:
|
|
196
|
+
return IntegrationResult(False, "A descrição Bilibili deve ter no máximo 2000 caracteres.", {"api": "bilibili-api-python"})
|
|
197
|
+
if not clean_tags or len(clean_tags) > BILIBILI_MAX_TAGS:
|
|
198
|
+
return IntegrationResult(False, "Indique entre 1 e 10 tags Bilibili, separadas por vírgulas.", {"api": "bilibili-api-python"})
|
|
199
|
+
try:
|
|
200
|
+
tid_value = int(tid)
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
return IntegrationResult(False, "O ID da secção Bilibili deve ser numérico.", {"api": "bilibili-api-python"})
|
|
203
|
+
cover = Path(cover_path).expanduser() if cover_path else None
|
|
204
|
+
if cover is not None and not cover.is_file():
|
|
205
|
+
return IntegrationResult(False, f"Capa Bilibili não encontrada: {cover}", {"api": "bilibili-api-python"})
|
|
206
|
+
try:
|
|
207
|
+
Credential, video_uploader = _import_sdk()
|
|
208
|
+
credential = _credential(self.card, Credential)
|
|
209
|
+
page = video_uploader.VideoUploaderPage(str(path), clean_title, clean_description)
|
|
210
|
+
meta: dict[str, Any] = {
|
|
211
|
+
"title": clean_title,
|
|
212
|
+
"copyright": 1 if original else 2,
|
|
213
|
+
"tid": tid_value,
|
|
214
|
+
"tag": ",".join(clean_tags),
|
|
215
|
+
"desc_format_id": 9999,
|
|
216
|
+
"desc": clean_description,
|
|
217
|
+
"recreate": -1,
|
|
218
|
+
"dynamic": str(dynamic or "")[:233],
|
|
219
|
+
"interactive": 0,
|
|
220
|
+
"act_reserve_create": 0,
|
|
221
|
+
"no_disturbance": 0,
|
|
222
|
+
"no_reprint": 0,
|
|
223
|
+
"subtitle": {"open": 0, "lan": ""},
|
|
224
|
+
"dolby": 0,
|
|
225
|
+
"lossless_music": 0,
|
|
226
|
+
"web_os": 1,
|
|
227
|
+
}
|
|
228
|
+
temporary_cover: tempfile.TemporaryDirectory[str] | None = None
|
|
229
|
+
if cover is None:
|
|
230
|
+
temporary_cover = tempfile.TemporaryDirectory(prefix="thunderbolt-bilibili-")
|
|
231
|
+
cover = _derive_cover(path, Path(temporary_cover.name))
|
|
232
|
+
try:
|
|
233
|
+
uploader = video_uploader.VideoUploader([page], meta, credential, cover=str(cover))
|
|
234
|
+
result = _run_async(uploader.start())
|
|
235
|
+
finally:
|
|
236
|
+
if temporary_cover is not None:
|
|
237
|
+
temporary_cover.cleanup()
|
|
238
|
+
payload = result if isinstance(result, dict) else {"response": result}
|
|
239
|
+
public_data = {key: payload.get(key) for key in ("bvid", "aid", "code", "message") if key in payload}
|
|
240
|
+
public_data.update({"account": self.card.get("label", "Conta Bilibili"), "api": "bilibili-api-python", "title": clean_title})
|
|
241
|
+
bvid = str(payload.get("bvid") or "").strip()
|
|
242
|
+
message = "Upload Bilibili concluído."
|
|
243
|
+
if bvid:
|
|
244
|
+
message += f" BVID: {bvid}."
|
|
245
|
+
return IntegrationResult(True, message, public_data)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
return IntegrationResult(False, f"Upload Bilibili falhou na API bilibili-api-python: {exc}", {"account": self.card.get("label", "Conta Bilibili"), "api": "bilibili-api-python"})
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
__all__ = [
|
|
251
|
+
"BILIBILI_API_VERSION",
|
|
252
|
+
"BILIBILI_DEFAULT_TID",
|
|
253
|
+
"BILIBILI_MAX_DESCRIPTION",
|
|
254
|
+
"BILIBILI_MAX_TAGS",
|
|
255
|
+
"BILIBILI_MAX_TITLE",
|
|
256
|
+
"BILIBILI_VIDEO_EXTENSIONS",
|
|
257
|
+
"BilibiliApiAdapter",
|
|
258
|
+
"normalise_bilibili_api_cards",
|
|
259
|
+
]
|