@danhachuel/thunderbolt 0.2.13
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 +513 -0
- package/README.md +173 -0
- package/app/main.py +624 -0
- package/hermes_ui/blueprints.py +157 -0
- package/hermes_ui/domain.py +149 -0
- package/hermes_ui/metadata_cleaner.py +190 -0
- package/hermes_ui/storage.py +220 -0
- package/integrations/local_runtime.py +24 -0
- package/integrations/moneyprinter_config.py +175 -0
- package/integrations/platforms.py +107 -0
- package/package.json +33 -0
- package/requirements.txt +5 -0
- package/scripts/cli.mjs +115 -0
- package/scripts/install.mjs +315 -0
- package/seed/blueprints/BLUEPRINTCANALMILITAR.json +72 -0
- package/seed/blueprints/BlueprintAnimalFacts-Felune.json +98 -0
- package/seed/blueprints/BlueprintCocomelon.json +329 -0
- package/seed/blueprints/BlueprintFilosofiaeEstoicismo.json +350 -0
- package/seed/blueprints/BlueprintHist/303/263ria.json +337 -0
- package/seed/blueprints/BlueprintMecanicaAutomotiva-Fatosmecanicos.json +54 -0
- package/seed/blueprints/BlueprintNatureVault.json +58 -0
- package/seed/blueprints/BlueprintOra/303/247/303/243o.json +338 -0
- package/seed/blueprints/BlueprintStickMan.json +50 -0
- package/seed/blueprints/BlueprintUniverso.json +308 -0
- package/seed/blueprints/BlueprintZackD-VoxelVault.json +54 -0
- package/seed/blueprints/CELEBRITIES.json +242 -0
- package/seed/blueprints/blueprintcanalfinan/303/247as.json +329 -0
- package/storage/blueprints/importados/demo-content-hermes.json +23 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .storage import BLUEPRINTS, now
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _slug(value: str) -> str:
|
|
13
|
+
value = re.sub(r"[^a-zA-Z0-9À-ÿ]+", "-", value.strip().lower()).strip("-")
|
|
14
|
+
return value or "canal"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_youtube_link(url: str) -> dict[str, str]:
|
|
18
|
+
value = url.strip()
|
|
19
|
+
if not value:
|
|
20
|
+
raise ValueError("Introduza um link do YouTube.")
|
|
21
|
+
video_id = ""
|
|
22
|
+
channel_ref = ""
|
|
23
|
+
input_type = "channel"
|
|
24
|
+
if "youtu.be/" in value:
|
|
25
|
+
video_id = value.split("youtu.be/", 1)[1].split("?", 1)[0].split("/", 1)[0]
|
|
26
|
+
input_type = "video"
|
|
27
|
+
elif "watch?v=" in value:
|
|
28
|
+
video_id = value.split("watch?v=", 1)[1].split("&", 1)[0]
|
|
29
|
+
input_type = "video"
|
|
30
|
+
elif "/shorts/" in value:
|
|
31
|
+
video_id = value.split("/shorts/", 1)[1].split("?", 1)[0].split("/", 1)[0]
|
|
32
|
+
input_type = "video"
|
|
33
|
+
elif "/@" in value:
|
|
34
|
+
channel_ref = value.split("/@", 1)[1].split("/", 1)[0].split("?", 1)[0]
|
|
35
|
+
input_type = "channel_handle"
|
|
36
|
+
elif "/channel/" in value:
|
|
37
|
+
channel_ref = value.split("/channel/", 1)[1].split("/", 1)[0].split("?", 1)[0]
|
|
38
|
+
input_type = "channel_id"
|
|
39
|
+
else:
|
|
40
|
+
channel_ref = value.rstrip("/").split("/")[-1].lstrip("@")
|
|
41
|
+
input_type = "channel_handle"
|
|
42
|
+
return {"original_url": value, "video_id": video_id, "channel_ref": channel_ref, "input_type": input_type}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_blueprint_from_link(url: str, niche: str, language: str, include_branding: bool, channel_name: str = "") -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
46
|
+
parsed = parse_youtube_link(url)
|
|
47
|
+
base_name = channel_name.strip() or parsed["channel_ref"] or "Canal analisado"
|
|
48
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
49
|
+
blueprint_id = f"bp_{uuid.uuid4().hex[:10]}"
|
|
50
|
+
blueprint = {
|
|
51
|
+
"id": blueprint_id,
|
|
52
|
+
"name": f"Blueprint — {base_name}",
|
|
53
|
+
"metadata": {
|
|
54
|
+
"task_type": "forensic_content_blueprint",
|
|
55
|
+
"source_url": parsed["original_url"],
|
|
56
|
+
"input_type": parsed["input_type"],
|
|
57
|
+
"video_id": parsed["video_id"],
|
|
58
|
+
"channel_ref": parsed["channel_ref"],
|
|
59
|
+
"target_niche": niche or "não definido",
|
|
60
|
+
"language": language,
|
|
61
|
+
"created_at": timestamp,
|
|
62
|
+
"status": "draft_local",
|
|
63
|
+
},
|
|
64
|
+
"channel_profile": {
|
|
65
|
+
"channel_name": base_name,
|
|
66
|
+
"handle": f"@{parsed['channel_ref']}" if parsed["channel_ref"] else "",
|
|
67
|
+
"description": "Preencher com dados importados do YouTube e análise forense.",
|
|
68
|
+
"audience": "",
|
|
69
|
+
"content_pillars": [],
|
|
70
|
+
},
|
|
71
|
+
"content_strategy": {
|
|
72
|
+
"niche": niche or "",
|
|
73
|
+
"format": "faceless",
|
|
74
|
+
"publishing_frequency": "",
|
|
75
|
+
"hook_patterns": [],
|
|
76
|
+
"title_formulas": [],
|
|
77
|
+
"script_structure": ["hook", "context", "development", "payoff", "cta"],
|
|
78
|
+
"full_script_target_characters": "7000-9000",
|
|
79
|
+
},
|
|
80
|
+
"research": {
|
|
81
|
+
"top_videos": [],
|
|
82
|
+
"sample_video": {},
|
|
83
|
+
"transcripts": [],
|
|
84
|
+
"source_notes": [],
|
|
85
|
+
},
|
|
86
|
+
"branding_id": "",
|
|
87
|
+
"version": 1,
|
|
88
|
+
}
|
|
89
|
+
branding = None
|
|
90
|
+
if include_branding:
|
|
91
|
+
branding = create_branding_for_blueprint(blueprint)
|
|
92
|
+
blueprint["branding_id"] = branding["id"]
|
|
93
|
+
return blueprint, branding
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def create_branding_for_blueprint(blueprint: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
metadata = blueprint.get("metadata", {})
|
|
98
|
+
channel = blueprint.get("channel_profile", {})
|
|
99
|
+
name = channel.get("channel_name") or blueprint.get("name", "Canal").replace("Blueprint — ", "")
|
|
100
|
+
branding = {
|
|
101
|
+
"id": f"branding_{uuid.uuid4().hex[:10]}",
|
|
102
|
+
"name": f"Branding — {name}",
|
|
103
|
+
"blueprint_id": blueprint.get("id", ""),
|
|
104
|
+
"source_url": metadata.get("source_url", ""),
|
|
105
|
+
"created_at": now(),
|
|
106
|
+
"status": "draft_local",
|
|
107
|
+
"identity": {
|
|
108
|
+
"channel_name": name,
|
|
109
|
+
"handle": channel.get("handle", ""),
|
|
110
|
+
"tagline": "",
|
|
111
|
+
"description": channel.get("description", ""),
|
|
112
|
+
"hashtags": [],
|
|
113
|
+
"keywords": [],
|
|
114
|
+
},
|
|
115
|
+
"visual_identity": {
|
|
116
|
+
"color_palette": [],
|
|
117
|
+
"typography": {"primary": "", "secondary": ""},
|
|
118
|
+
"profile_image_prompt": f"Logo de perfil para o canal faceless {name}, nicho {metadata.get('target_niche', '')}, visual memorável, sem texto pequeno.",
|
|
119
|
+
"banner_prompt": f"Banner de YouTube para {name}, nicho {metadata.get('target_niche', '')}, composição cinematográfica, espaço seguro para texto.",
|
|
120
|
+
"thumbnail_direction": "alto contraste, uma ideia visual principal, máximo quatro palavras",
|
|
121
|
+
},
|
|
122
|
+
"assets": {
|
|
123
|
+
"profile_image": "",
|
|
124
|
+
"banner": "",
|
|
125
|
+
"watermark": "",
|
|
126
|
+
"brand_pack_path": "",
|
|
127
|
+
},
|
|
128
|
+
"checklist": {
|
|
129
|
+
"name_reviewed": False,
|
|
130
|
+
"handle_reviewed": False,
|
|
131
|
+
"description_reviewed": False,
|
|
132
|
+
"profile_prompt_reviewed": False,
|
|
133
|
+
"banner_prompt_reviewed": False,
|
|
134
|
+
"assets_generated": False,
|
|
135
|
+
},
|
|
136
|
+
"version": 1,
|
|
137
|
+
}
|
|
138
|
+
return branding
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def save_generated_blueprint(blueprint: dict[str, Any], branding: dict[str, Any] | None = None) -> tuple[Path, Path | None]:
|
|
142
|
+
BLUEPRINTS.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
target = BLUEPRINTS / "canais" / f"{_slug(blueprint.get('name', 'blueprint'))}-{blueprint['id']}.json"
|
|
144
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
target.write_text(__import__("json").dumps(blueprint, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
146
|
+
branding_path = None
|
|
147
|
+
if branding:
|
|
148
|
+
branding_path = BLUEPRINTS / "brandings" / f"{_slug(branding.get('name', 'branding'))}-{branding['id']}.json"
|
|
149
|
+
branding_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
branding_path.write_text(__import__("json").dumps(branding, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
151
|
+
return target, branding_path
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def list_branding_files() -> list[Path]:
|
|
155
|
+
folder = BLUEPRINTS / "brandings"
|
|
156
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
return sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .storage import append_json, now, read_json, write_json
|
|
8
|
+
|
|
9
|
+
STAGES = ["niche", "blueprint", "brand", "script", "title", "thumbnail", "video", "edit", "upload"]
|
|
10
|
+
VALID_STATES = {"to_do", "doing", "blocked", "done", "failed", "cancelled"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def slugify(value: str) -> str:
|
|
14
|
+
value = re.sub(r"[^a-zA-Z0-9À-ÿ]+", "-", value.strip().lower()).strip("-")
|
|
15
|
+
return value or "item"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_id(prefix: str) -> str:
|
|
19
|
+
return f"{prefix}_{uuid.uuid4().hex[:10]}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
23
|
+
channel = {
|
|
24
|
+
"id": make_id("channel"),
|
|
25
|
+
"name": name.strip(),
|
|
26
|
+
"url": url.strip(),
|
|
27
|
+
"handle": "",
|
|
28
|
+
"description": "",
|
|
29
|
+
"thumbnail_url": "",
|
|
30
|
+
"subscriber_count": None,
|
|
31
|
+
"video_count": None,
|
|
32
|
+
"view_count": None,
|
|
33
|
+
"metrics_source": "manual",
|
|
34
|
+
"last_youtube_sync": None,
|
|
35
|
+
"language": "Português",
|
|
36
|
+
"blueprint_id": "",
|
|
37
|
+
"style_wide": "pexels",
|
|
38
|
+
"voice": "",
|
|
39
|
+
"active": True,
|
|
40
|
+
"daily_limit": 1,
|
|
41
|
+
"backlog_total": 0,
|
|
42
|
+
"created_at": now(),
|
|
43
|
+
"updated_at": now(),
|
|
44
|
+
}
|
|
45
|
+
if metadata:
|
|
46
|
+
channel.update({k: v for k, v in metadata.items() if k in channel})
|
|
47
|
+
channels = read_json("channels.json", [])
|
|
48
|
+
channels.append(channel)
|
|
49
|
+
write_json("channels.json", channels)
|
|
50
|
+
return channel
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
54
|
+
channels = read_json("channels.json", [])
|
|
55
|
+
for channel in channels:
|
|
56
|
+
if channel.get("id") == channel_id:
|
|
57
|
+
channel.update(updates)
|
|
58
|
+
channel["updated_at"] = now()
|
|
59
|
+
write_json("channels.json", channels)
|
|
60
|
+
return channel
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_batch(mode: str, channel_ids: list[str], topic: str, quantity: int, options: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
batch = {
|
|
66
|
+
"id": make_id("batch"),
|
|
67
|
+
"mode": mode,
|
|
68
|
+
"channel_ids": channel_ids,
|
|
69
|
+
"topic": topic.strip(),
|
|
70
|
+
"quantity": quantity,
|
|
71
|
+
"status": "to_do",
|
|
72
|
+
"created_at": now(),
|
|
73
|
+
"options": options,
|
|
74
|
+
}
|
|
75
|
+
append_json("batches.json", batch)
|
|
76
|
+
return batch
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
80
|
+
tasks = read_json("tasks.json", [])
|
|
81
|
+
channels = {c["id"]: c for c in read_json("channels.json", [])}
|
|
82
|
+
target_channels = batch["channel_ids"]
|
|
83
|
+
count = batch["quantity"]
|
|
84
|
+
if batch["mode"] == "general":
|
|
85
|
+
count = 1
|
|
86
|
+
created: list[dict[str, Any]] = []
|
|
87
|
+
for channel_id in target_channels:
|
|
88
|
+
for index in range(count):
|
|
89
|
+
channel = channels.get(channel_id, {})
|
|
90
|
+
task = {
|
|
91
|
+
"id": make_id("video"),
|
|
92
|
+
"batch_id": batch["id"],
|
|
93
|
+
"creation_mode": batch["mode"],
|
|
94
|
+
"channel_id": channel_id,
|
|
95
|
+
"channel_name": channel.get("name", "Canal"),
|
|
96
|
+
"topic": batch["topic"] if count == 1 else f"{batch['topic']} — variação {index + 1}",
|
|
97
|
+
"language": batch["options"].get("language", channel.get("language", "Português")),
|
|
98
|
+
"format": batch["options"].get("format", "wide"),
|
|
99
|
+
"style_wide": batch["options"].get("style_wide", channel.get("style_wide", "pexels")),
|
|
100
|
+
"stage": "script",
|
|
101
|
+
"state": "to_do",
|
|
102
|
+
"progress": 0,
|
|
103
|
+
"artifacts": {},
|
|
104
|
+
"error": None,
|
|
105
|
+
"created_at": now(),
|
|
106
|
+
"updated_at": now(),
|
|
107
|
+
}
|
|
108
|
+
tasks.append(task)
|
|
109
|
+
created.append(task)
|
|
110
|
+
write_json("tasks.json", tasks)
|
|
111
|
+
for task in created:
|
|
112
|
+
queues = read_json("queues.json", {})
|
|
113
|
+
queues.setdefault("script", []).append(task["id"])
|
|
114
|
+
write_json("queues.json", queues)
|
|
115
|
+
return created
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def transition_task(task_id: str, state: str | None = None, stage: str | None = None, error: str | None = None) -> dict[str, Any] | None:
|
|
119
|
+
if state and state not in VALID_STATES:
|
|
120
|
+
raise ValueError(f"Estado inválido: {state}")
|
|
121
|
+
tasks = read_json("tasks.json", [])
|
|
122
|
+
for task in tasks:
|
|
123
|
+
if task.get("id") == task_id:
|
|
124
|
+
if state:
|
|
125
|
+
task["state"] = state
|
|
126
|
+
if stage:
|
|
127
|
+
if stage not in STAGES:
|
|
128
|
+
raise ValueError(f"Etapa inválida: {stage}")
|
|
129
|
+
task["stage"] = stage
|
|
130
|
+
if error is not None:
|
|
131
|
+
task["error"] = error
|
|
132
|
+
task["updated_at"] = now()
|
|
133
|
+
write_json("tasks.json", tasks)
|
|
134
|
+
return task
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def pipeline_summary() -> dict[str, Any]:
|
|
139
|
+
tasks = read_json("tasks.json", [])
|
|
140
|
+
channels = read_json("channels.json", [])
|
|
141
|
+
return {
|
|
142
|
+
"channels": len(channels),
|
|
143
|
+
"active_channels": sum(1 for c in channels if c.get("active")),
|
|
144
|
+
"total_tasks": len(tasks),
|
|
145
|
+
"done": sum(1 for t in tasks if t.get("state") == "done"),
|
|
146
|
+
"pending": sum(1 for t in tasks if t.get("state") in {"to_do", "doing", "blocked"}),
|
|
147
|
+
"failed": sum(1 for t in tasks if t.get("state") == "failed"),
|
|
148
|
+
"doing": sum(1 for t in tasks if t.get("state") == "doing"),
|
|
149
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import unicodedata
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import storage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _metadata_root() -> Path:
|
|
17
|
+
return storage.STORAGE / "metadata_cleaner"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _originals() -> Path:
|
|
21
|
+
return _metadata_root() / "originals"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _outputs() -> Path:
|
|
25
|
+
return _metadata_root() / "outputs"
|
|
26
|
+
|
|
27
|
+
VIDEO_EXTENSIONS = ["mp4", "mov", "mkv", "webm", "avi", "m4v", "mpeg", "mpg"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _now() -> str:
|
|
31
|
+
return datetime.now(timezone.utc).isoformat()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _safe_filename(name: str) -> str:
|
|
35
|
+
normalized = unicodedata.normalize("NFKD", Path(name).name)
|
|
36
|
+
ascii_name = normalized.encode("ascii", "ignore").decode("ascii")
|
|
37
|
+
ascii_name = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_name).strip(".-")
|
|
38
|
+
return ascii_name or "video-terceiro.mp4"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ensure_directories() -> None:
|
|
42
|
+
_originals().mkdir(parents=True, exist_ok=True)
|
|
43
|
+
_outputs().mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def store_external_video(filename: str, content: bytes) -> tuple[Path, str]:
|
|
47
|
+
"""Persist an uploaded third-party video by content hash without overwriting it."""
|
|
48
|
+
if not content:
|
|
49
|
+
raise ValueError("O ficheiro de vídeo está vazio.")
|
|
50
|
+
_ensure_directories()
|
|
51
|
+
digest = hashlib.sha256(content).hexdigest()
|
|
52
|
+
safe_name = _safe_filename(filename)
|
|
53
|
+
path = _originals() / f"{digest[:16]}-{safe_name}"
|
|
54
|
+
if not path.exists():
|
|
55
|
+
path.write_bytes(content)
|
|
56
|
+
return path, digest
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_ffmpeg(configured_path: str | None = None) -> str:
|
|
60
|
+
configured = (configured_path or "").strip()
|
|
61
|
+
if configured:
|
|
62
|
+
candidate = Path(configured).expanduser()
|
|
63
|
+
if candidate.is_file():
|
|
64
|
+
return str(candidate)
|
|
65
|
+
resolved = shutil.which(configured)
|
|
66
|
+
if resolved:
|
|
67
|
+
return resolved
|
|
68
|
+
resolved = shutil.which("ffmpeg")
|
|
69
|
+
if resolved:
|
|
70
|
+
return resolved
|
|
71
|
+
try:
|
|
72
|
+
import imageio_ffmpeg
|
|
73
|
+
|
|
74
|
+
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
75
|
+
except Exception as exc: # pragma: no cover - depends on local installation
|
|
76
|
+
raise RuntimeError("FFmpeg não foi encontrado. Instale-o ou configure o caminho em Configurações.") from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_description(preview: str, links: str, timestamps: str) -> str:
|
|
80
|
+
"""Match the n8n workflow's preview + links + timestamps description format."""
|
|
81
|
+
sections: list[str] = []
|
|
82
|
+
if preview.strip():
|
|
83
|
+
sections.append(preview.strip())
|
|
84
|
+
if links.strip():
|
|
85
|
+
link_lines = links.strip()
|
|
86
|
+
if not link_lines.lower().startswith("links:"):
|
|
87
|
+
link_lines = "Links:\n" + link_lines
|
|
88
|
+
sections.append(link_lines)
|
|
89
|
+
if timestamps.strip():
|
|
90
|
+
sections.append(timestamps.strip())
|
|
91
|
+
return "\n\n".join(sections).strip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def normalize_tags(tags: str | list[str]) -> list[str]:
|
|
95
|
+
if isinstance(tags, list):
|
|
96
|
+
values = tags
|
|
97
|
+
else:
|
|
98
|
+
values = re.split(r"[,;\n]", tags)
|
|
99
|
+
result: list[str] = []
|
|
100
|
+
seen: set[str] = set()
|
|
101
|
+
for value in values:
|
|
102
|
+
cleaned = re.sub(r"^#+", "", str(value).strip())
|
|
103
|
+
if cleaned and cleaned.lower() not in seen:
|
|
104
|
+
result.append(cleaned)
|
|
105
|
+
seen.add(cleaned.lower())
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _ffmpeg_metadata_args(metadata: dict[str, Any]) -> list[str]:
|
|
110
|
+
args: list[str] = ["-map_metadata", "-1"]
|
|
111
|
+
mapping = {
|
|
112
|
+
"title": "title",
|
|
113
|
+
"description": "description",
|
|
114
|
+
"comment": "comment",
|
|
115
|
+
"language": "language",
|
|
116
|
+
"creator": "artist",
|
|
117
|
+
"copyright": "copyright",
|
|
118
|
+
"date": "date",
|
|
119
|
+
"genre": "genre",
|
|
120
|
+
"album": "album",
|
|
121
|
+
}
|
|
122
|
+
for field, ffmpeg_key in mapping.items():
|
|
123
|
+
value = str(metadata.get(field, "") or "").strip()
|
|
124
|
+
if value:
|
|
125
|
+
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
|
|
126
|
+
tags = normalize_tags(metadata.get("tags", ""))
|
|
127
|
+
if tags:
|
|
128
|
+
args.extend(["-metadata", f"keywords={', '.join(tags)}"])
|
|
129
|
+
args.extend(["-metadata", f"synopsis={', '.join(tags)}"])
|
|
130
|
+
return args
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def clean_video_metadata(
|
|
134
|
+
source: Path,
|
|
135
|
+
metadata: dict[str, Any],
|
|
136
|
+
*,
|
|
137
|
+
ffmpeg_path: str | None = None,
|
|
138
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
139
|
+
"""Strip existing container metadata and write a clean third-party copy."""
|
|
140
|
+
if not source.is_file():
|
|
141
|
+
raise FileNotFoundError("O vídeo externo não foi encontrado no armazenamento local.")
|
|
142
|
+
_ensure_directories()
|
|
143
|
+
ffmpeg = _resolve_ffmpeg(ffmpeg_path)
|
|
144
|
+
source_name = _safe_filename(source.name)
|
|
145
|
+
output = _outputs() / f"limpo-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{source_name}"
|
|
146
|
+
command = [ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", str(source)]
|
|
147
|
+
command.extend(_ffmpeg_metadata_args(metadata))
|
|
148
|
+
command.extend(["-c", "copy", str(output)])
|
|
149
|
+
completed = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
150
|
+
if completed.returncode != 0 or not output.exists() or output.stat().st_size == 0:
|
|
151
|
+
if output.exists():
|
|
152
|
+
output.unlink()
|
|
153
|
+
detail = (completed.stderr or completed.stdout or "erro desconhecido").strip()
|
|
154
|
+
raise RuntimeError(f"FFmpeg não conseguiu limpar os metadados: {detail[-1200:]}")
|
|
155
|
+
return output, {
|
|
156
|
+
"ffmpeg": ffmpeg,
|
|
157
|
+
"command": command,
|
|
158
|
+
"source": str(source),
|
|
159
|
+
"output": str(output),
|
|
160
|
+
"created_at": _now(),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def save_edit_record(source: Path, output: Path, metadata: dict[str, Any], run_info: dict[str, Any]) -> dict[str, Any]:
|
|
165
|
+
tags = normalize_tags(metadata.get("tags", ""))
|
|
166
|
+
record = {
|
|
167
|
+
"id": hashlib.sha256(f"{source}:{output}".encode("utf-8")).hexdigest()[:16],
|
|
168
|
+
"source_type": "third_party_video",
|
|
169
|
+
"source_name": source.name,
|
|
170
|
+
"source_path": str(source),
|
|
171
|
+
"output_name": output.name,
|
|
172
|
+
"output_path": str(output),
|
|
173
|
+
"metadata": {**metadata, "tags": tags},
|
|
174
|
+
"run": {key: value for key, value in run_info.items() if key != "command"},
|
|
175
|
+
"created_at": run_info.get("created_at", _now()),
|
|
176
|
+
}
|
|
177
|
+
storage.append_json("metadata_edits.json", record)
|
|
178
|
+
return record
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def list_edit_records() -> list[dict[str, Any]]:
|
|
182
|
+
records = storage.read_json("metadata_edits.json", [])
|
|
183
|
+
if not isinstance(records, list):
|
|
184
|
+
return []
|
|
185
|
+
return list(reversed(records))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def metadata_manifest(record: dict[str, Any]) -> bytes:
|
|
189
|
+
"""Return a portable JSON sidecar for YouTube title/description/tags upload."""
|
|
190
|
+
return (json.dumps(record, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|