aurclips 0.1.0__py3-none-any.whl
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.
- aurclips/__init__.py +3 -0
- aurclips/__main__.py +366 -0
- aurclips/assets/config.default.yaml +204 -0
- aurclips/assets/fonts/Anton-Regular.ttf +0 -0
- aurclips/assets/fonts/OFL.txt +93 -0
- aurclips/clipper.py +118 -0
- aurclips/config.py +184 -0
- aurclips/facecrop.py +1233 -0
- aurclips/heuristics.py +449 -0
- aurclips/ingest.py +143 -0
- aurclips/marks.py +308 -0
- aurclips/notify.py +80 -0
- aurclips/render.py +219 -0
- aurclips/runner.py +119 -0
- aurclips/safety.py +221 -0
- aurclips/select_clips.py +165 -0
- aurclips/state.py +444 -0
- aurclips/stats.py +324 -0
- aurclips/subtitles.py +152 -0
- aurclips/titles.py +130 -0
- aurclips/transcribe.py +202 -0
- aurclips/upload.py +175 -0
- aurclips-0.1.0.dist-info/METADATA +257 -0
- aurclips-0.1.0.dist-info/RECORD +27 -0
- aurclips-0.1.0.dist-info/WHEEL +4 -0
- aurclips-0.1.0.dist-info/entry_points.txt +2 -0
- aurclips-0.1.0.dist-info/licenses/LICENSE +21 -0
aurclips/__init__.py
ADDED
aurclips/__main__.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""CLI de aurclips.
|
|
2
|
+
|
|
3
|
+
Uso:
|
|
4
|
+
python -m aurclips clip RUTA # recortar una grabación y ya (sin pipeline)
|
|
5
|
+
python -m aurclips run # pipeline completo (ingesta -> proceso -> subida)
|
|
6
|
+
python -m aurclips mark # marcar momentos en vivo mientras grabas
|
|
7
|
+
python -m aurclips review # aprobar o corregir títulos antes de subir
|
|
8
|
+
python -m aurclips ingest # solo buscar/descargar contenido nuevo
|
|
9
|
+
python -m aurclips process # solo transcribir + seleccionar + renderizar
|
|
10
|
+
python -m aurclips upload # solo subir clips renderizados
|
|
11
|
+
python -m aurclips auth # iniciar sesión de YouTube (una sola vez)
|
|
12
|
+
python -m aurclips status # ver estado de videos y clips
|
|
13
|
+
python -m aurclips report # métricas de los Shorts publicados y cola
|
|
14
|
+
python -m aurclips retry # reencolar videos/clips fallidos
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import traceback
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .config import Config
|
|
26
|
+
from .state import State
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load() -> tuple[Config, State]:
|
|
30
|
+
cfg = Config()
|
|
31
|
+
db = State(cfg.db_path)
|
|
32
|
+
return cfg, db
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_ingest(cfg: Config, db: State):
|
|
36
|
+
from .ingest import ingest
|
|
37
|
+
ingest(cfg, db)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_process(cfg: Config, db: State):
|
|
41
|
+
from .render import render_clip
|
|
42
|
+
from .safety import screen_clip
|
|
43
|
+
from .select_clips import clip_words, select_clips
|
|
44
|
+
from .transcribe import transcribe
|
|
45
|
+
|
|
46
|
+
videos = db.videos_to_process()
|
|
47
|
+
if not videos:
|
|
48
|
+
print("[2/4] No hay videos pendientes por procesar")
|
|
49
|
+
return
|
|
50
|
+
max_videos = cfg.get("limits.max_videos_per_run", 3)
|
|
51
|
+
if len(videos) > max_videos:
|
|
52
|
+
print(f"[2/4] {len(videos)} pendientes; se procesan {max_videos} "
|
|
53
|
+
f"(limits.max_videos_per_run)")
|
|
54
|
+
videos = videos[:max_videos]
|
|
55
|
+
for video in videos:
|
|
56
|
+
vid = video["id"]
|
|
57
|
+
title = video["title"] or f"video_{vid}"
|
|
58
|
+
workdir = cfg.work_dir / f"video_{vid}"
|
|
59
|
+
transcript_path = workdir / "transcript.json"
|
|
60
|
+
try:
|
|
61
|
+
# --- transcribir -------------------------------------------
|
|
62
|
+
if db.needs_transcription(video):
|
|
63
|
+
print(f"[2/4] Transcribiendo: {title}")
|
|
64
|
+
transcript = transcribe(cfg, video["path"], transcript_path)
|
|
65
|
+
db.video_transcribed(vid)
|
|
66
|
+
else:
|
|
67
|
+
transcript = json.loads(transcript_path.read_text(encoding="utf-8"))
|
|
68
|
+
|
|
69
|
+
# --- seleccionar clips -------------------------------------
|
|
70
|
+
if db.needs_selection(video):
|
|
71
|
+
print(f"[3/4] Seleccionando clips: {title}")
|
|
72
|
+
clips = select_clips(cfg, transcript, title, video["path"])
|
|
73
|
+
if not clips:
|
|
74
|
+
print(" sin clips útiles; video marcado como terminado")
|
|
75
|
+
db.video_finished(vid)
|
|
76
|
+
continue
|
|
77
|
+
added = 0
|
|
78
|
+
for i, c in enumerate(clips):
|
|
79
|
+
text = " ".join(
|
|
80
|
+
w["word"] for w in clip_words(transcript, c.start, c.end)
|
|
81
|
+
)
|
|
82
|
+
known = ((row["id"], row["text"])
|
|
83
|
+
for row in db.texts_for_dedup())
|
|
84
|
+
verdict = screen_clip(cfg, text, known)
|
|
85
|
+
if verdict.unsafe_terms:
|
|
86
|
+
print(f" [filtro] clip {c.title!r} contiene: "
|
|
87
|
+
f"{', '.join(verdict.unsafe_terms[:5])} -> "
|
|
88
|
+
f"{cfg.get('safety.action', 'skip')}")
|
|
89
|
+
if verdict.duplicate_of is not None:
|
|
90
|
+
print(f" [dedup] clip {c.title!r} es casi idéntico "
|
|
91
|
+
f"al clip #{verdict.duplicate_of}; se omite")
|
|
92
|
+
if not verdict.keep:
|
|
93
|
+
continue
|
|
94
|
+
db.add_clip(vid, i, c, text, flagged=verdict.flagged)
|
|
95
|
+
added += 1
|
|
96
|
+
if not added:
|
|
97
|
+
print(" todos los clips fueron filtrados; video terminado")
|
|
98
|
+
db.video_finished(vid)
|
|
99
|
+
continue
|
|
100
|
+
db.video_selected(vid)
|
|
101
|
+
|
|
102
|
+
# --- renderizar --------------------------------------------
|
|
103
|
+
for clip in db.clips_to_render(vid):
|
|
104
|
+
words = clip_words(transcript, clip["start"], clip["end"])
|
|
105
|
+
out = render_clip(cfg, video["path"], clip["start"], clip["end"],
|
|
106
|
+
clip["title"], words, clip["id"])
|
|
107
|
+
db.clip_rendered(clip["id"], str(out))
|
|
108
|
+
db.video_finished(vid)
|
|
109
|
+
except Exception as e: # noqa: BLE001 — un video fallido no detiene el resto
|
|
110
|
+
print(f" [error] video {vid} ({title}): {e}")
|
|
111
|
+
traceback.print_exc()
|
|
112
|
+
db.video_failed(vid, str(e))
|
|
113
|
+
from .notify import notify
|
|
114
|
+
notify(cfg, "error", f"Falló el video '{title}': {str(e)[:200]}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_mark(cfg: Config, db: State, name: str | None = None):
|
|
118
|
+
"""Sesión de marcado en vivo: cada Enter marca el instante actual."""
|
|
119
|
+
from .marks import record_session
|
|
120
|
+
record_session(cfg, name)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _show_clip(clip, tags: list[str]):
|
|
124
|
+
dur = clip["end"] - clip["start"]
|
|
125
|
+
star = " ★ marcado por ti" if clip["marked"] else ""
|
|
126
|
+
print(f"\n── clip #{clip['id']} · {clip['start']:.0f}s-{clip['end']:.0f}s "
|
|
127
|
+
f"({dur:.0f}s){star}")
|
|
128
|
+
print(f" archivo: {clip['path']}")
|
|
129
|
+
print(f" título: {clip['title']}")
|
|
130
|
+
if clip["description"]:
|
|
131
|
+
print(f" desc: {clip['description'][:160]}")
|
|
132
|
+
if tags:
|
|
133
|
+
print(f" tags: {' '.join('#' + t for t in tags)}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_review(cfg: Config, db: State):
|
|
137
|
+
"""Revisa y aprueba la metadata antes de que los clips se suban.
|
|
138
|
+
|
|
139
|
+
Son unos pocos al día: la herramienta propone y tú apruebas. Es el punto
|
|
140
|
+
donde tu criterio entra al pipeline sin tener que tocar código.
|
|
141
|
+
"""
|
|
142
|
+
from . import titles
|
|
143
|
+
from .stats import review_header
|
|
144
|
+
|
|
145
|
+
clips = db.clips_to_review()
|
|
146
|
+
if not clips:
|
|
147
|
+
print("No hay clips esperando revisión.")
|
|
148
|
+
return
|
|
149
|
+
llm = titles.enabled(cfg)
|
|
150
|
+
for line in review_header(db): # los datos, donde se toman las decisiones
|
|
151
|
+
print(line)
|
|
152
|
+
print()
|
|
153
|
+
print(f"{len(clips)} clip(s) por revisar.")
|
|
154
|
+
print("[Enter] aprobar · [t] título · [d] descripción · "
|
|
155
|
+
f"{'[r] regenerar · ' if llm else ''}[x] descartar · [s] saltar · [q] salir")
|
|
156
|
+
|
|
157
|
+
approved = discarded = 0
|
|
158
|
+
for clip in clips:
|
|
159
|
+
tags = db.clip_tags(clip)
|
|
160
|
+
title, description = clip["title"], clip["description"]
|
|
161
|
+
_show_clip(clip, tags)
|
|
162
|
+
while True:
|
|
163
|
+
try:
|
|
164
|
+
choice = input(" > ").strip().lower()
|
|
165
|
+
except (EOFError, KeyboardInterrupt):
|
|
166
|
+
print("\nRevisión interrumpida; el resto queda pendiente.")
|
|
167
|
+
return
|
|
168
|
+
if choice in ("", "a", "ok"):
|
|
169
|
+
db.clip_approved(clip["id"], title, description, tags)
|
|
170
|
+
approved += 1
|
|
171
|
+
break
|
|
172
|
+
if choice == "t":
|
|
173
|
+
new = input(" título: ").strip()
|
|
174
|
+
if new:
|
|
175
|
+
title = new[:93]
|
|
176
|
+
print(f" título: {title}")
|
|
177
|
+
continue
|
|
178
|
+
if choice == "d":
|
|
179
|
+
new = input(" descripción: ").strip()
|
|
180
|
+
if new:
|
|
181
|
+
description = new
|
|
182
|
+
continue
|
|
183
|
+
if choice == "r" and llm:
|
|
184
|
+
proposal = titles.propose(cfg, clip["text"] or description, "")
|
|
185
|
+
if proposal: # se guarda al aprobar, no antes
|
|
186
|
+
title, description, tags = proposal
|
|
187
|
+
print(f" título: {title}")
|
|
188
|
+
print(f" desc: {description[:160]}")
|
|
189
|
+
continue
|
|
190
|
+
if choice == "x":
|
|
191
|
+
db.clip_discarded(clip["id"])
|
|
192
|
+
discarded += 1
|
|
193
|
+
print(" descartado (no se subirá)")
|
|
194
|
+
break
|
|
195
|
+
if choice == "s":
|
|
196
|
+
break
|
|
197
|
+
if choice == "q":
|
|
198
|
+
print(f"\n{approved} aprobado(s), {discarded} descartado(s).")
|
|
199
|
+
return
|
|
200
|
+
print(" opciones: Enter / t / d / r / x / s / q")
|
|
201
|
+
|
|
202
|
+
print(f"\n{approved} aprobado(s), {discarded} descartado(s).")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cmd_upload(cfg: Config, db: State):
|
|
206
|
+
from .upload import upload_pending
|
|
207
|
+
upload_pending(cfg, db)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_auth(cfg: Config, db: State):
|
|
211
|
+
from .upload import get_credentials
|
|
212
|
+
get_credentials(cfg, interactive=True)
|
|
213
|
+
print("Autenticación de YouTube completada.")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def cmd_status(cfg: Config, db: State):
|
|
217
|
+
print("== Videos ==")
|
|
218
|
+
rows = db.recent_videos(20)
|
|
219
|
+
if not rows:
|
|
220
|
+
print(" (ninguno)")
|
|
221
|
+
for r in rows:
|
|
222
|
+
dur = f"{r['duration']:.0f}s" if r["duration"] else "?"
|
|
223
|
+
print(f" #{r['id']:<4} {r['status']:<12} [{r['source']}] {r['title'] or ''} ({dur})")
|
|
224
|
+
print("\n== Clips ==")
|
|
225
|
+
rows = db.recent_clips(20)
|
|
226
|
+
if not rows:
|
|
227
|
+
print(" (ninguno)")
|
|
228
|
+
review_on = cfg.get("review.enabled", True)
|
|
229
|
+
for r in rows:
|
|
230
|
+
extra = ""
|
|
231
|
+
situation = db.clip_situation(r, review_on)
|
|
232
|
+
if situation == "published":
|
|
233
|
+
extra = f" -> https://youtu.be/{r['youtube_id']} @ {r['publish_at'] or '?'}"
|
|
234
|
+
elif situation == "discarded":
|
|
235
|
+
extra = " (descartado en revisión)"
|
|
236
|
+
elif situation == "awaiting_review":
|
|
237
|
+
extra = " (por revisar)"
|
|
238
|
+
star = "★" if r["marked"] else " "
|
|
239
|
+
print(f" #{r['id']:<4} {star} {r['status']:<10} {r['title'] or ''}{extra}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def cmd_report(cfg: Config, db: State):
|
|
243
|
+
from .stats import build_report, fetch_stats
|
|
244
|
+
if cfg.get("stats.enabled", True):
|
|
245
|
+
n = fetch_stats(cfg, db)
|
|
246
|
+
if n:
|
|
247
|
+
print(f"(métricas actualizadas para {n} clip(s))\n")
|
|
248
|
+
print(build_report(db))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def cmd_retry(cfg: Config, db: State):
|
|
252
|
+
"""Reencola videos y clips fallidos."""
|
|
253
|
+
def has_transcript(video_id: int) -> bool:
|
|
254
|
+
return (cfg.work_dir / f"video_{video_id}" / "transcript.json").exists()
|
|
255
|
+
|
|
256
|
+
n = db.requeue_failed(has_transcript)
|
|
257
|
+
print(f"{n} elemento(s) reencolados. Corre 'run' para reintentarlos.")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_run(cfg: Config, db: State):
|
|
261
|
+
"""Corrida diaria: se protege contra solapes y deja su propio log.
|
|
262
|
+
|
|
263
|
+
El lock reemplaza el -MultipleInstances del Task Scheduler (cron/launchd no
|
|
264
|
+
lo dan); la captura y rotación del log vivían en run.ps1 y ahora son iguales
|
|
265
|
+
en los tres SO, así que el scheduler solo llama a `aurclips run`.
|
|
266
|
+
"""
|
|
267
|
+
from datetime import datetime
|
|
268
|
+
|
|
269
|
+
from .runner import prune_run_logs, single_instance, tee_output
|
|
270
|
+
|
|
271
|
+
logs_dir = cfg.logs_dir
|
|
272
|
+
with single_instance(logs_dir / "run.lock") as acquired:
|
|
273
|
+
if not acquired:
|
|
274
|
+
print("Ya hay una corrida en marcha; esta se omite.")
|
|
275
|
+
return
|
|
276
|
+
stamp = datetime.now().strftime("%Y-%m-%d_%H%M")
|
|
277
|
+
with tee_output(logs_dir / f"run_{stamp}.log"):
|
|
278
|
+
_run_pipeline(cfg, db)
|
|
279
|
+
prune_run_logs(logs_dir, keep=30)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _run_pipeline(cfg: Config, db: State):
|
|
283
|
+
from .notify import notify
|
|
284
|
+
cmd_ingest(cfg, db)
|
|
285
|
+
cmd_process(cfg, db)
|
|
286
|
+
cmd_upload(cfg, db)
|
|
287
|
+
uploaded = db.count_published()
|
|
288
|
+
queued = db.count_queued()
|
|
289
|
+
notify(cfg, "run",
|
|
290
|
+
f"Corrida completa: {uploaded} clips subidos en total, {queued} en cola")
|
|
291
|
+
print("\nCorrida completa.")
|
|
292
|
+
# la corrida diaria pasa de madrugada: si algo espera tu criterio, que te
|
|
293
|
+
# busque a ti — el loop no se cierra solo
|
|
294
|
+
pending = len(db.clips_to_review())
|
|
295
|
+
if pending and cfg.get("review.enabled", True):
|
|
296
|
+
print(f"{pending} clip(s) esperan tu visto bueno: python -m aurclips review")
|
|
297
|
+
notify(cfg, "review",
|
|
298
|
+
f"{pending} clip(s) listos y esperando tu revisión "
|
|
299
|
+
f"(python -m aurclips review)")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cmd_clip(cfg: Config, path: str | None, out: str | None,
|
|
303
|
+
max_clips: int | None):
|
|
304
|
+
"""Modo recortador: una grabación entra, salen recortes sueltos.
|
|
305
|
+
|
|
306
|
+
El único comando que no abre la base: no hay progreso ni criterio que
|
|
307
|
+
guardar. Por eso recibe la config y no el par (cfg, db).
|
|
308
|
+
"""
|
|
309
|
+
from .clipper import clip_recording
|
|
310
|
+
|
|
311
|
+
if not path:
|
|
312
|
+
print("Falta la grabación: aurclips clip RUTA_DEL_VIDEO")
|
|
313
|
+
sys.exit(2)
|
|
314
|
+
if max_clips is not None and max_clips < 1:
|
|
315
|
+
print("--clips es un tope: tiene que ser 1 o más")
|
|
316
|
+
sys.exit(2)
|
|
317
|
+
try:
|
|
318
|
+
clip_recording(cfg, path, out, max_clips)
|
|
319
|
+
except (OSError, ValueError) as e:
|
|
320
|
+
print(f"[error] {e}")
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
COMMANDS = {
|
|
325
|
+
"run": cmd_run,
|
|
326
|
+
"mark": cmd_mark,
|
|
327
|
+
"review": cmd_review,
|
|
328
|
+
"ingest": cmd_ingest,
|
|
329
|
+
"process": cmd_process,
|
|
330
|
+
"upload": cmd_upload,
|
|
331
|
+
"auth": cmd_auth,
|
|
332
|
+
"status": cmd_status,
|
|
333
|
+
"report": cmd_report,
|
|
334
|
+
"retry": cmd_retry,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main():
|
|
339
|
+
parser = argparse.ArgumentParser(prog="aurclips", description=__doc__,
|
|
340
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
341
|
+
parser.add_argument("command", choices=["clip", *COMMANDS])
|
|
342
|
+
parser.add_argument("name", nargs="?",
|
|
343
|
+
help="ruta de la grabación (para 'clip') o nombre de "
|
|
344
|
+
"la sesión (para 'mark')")
|
|
345
|
+
parser.add_argument("--out", metavar="CARPETA",
|
|
346
|
+
help="dónde dejar los recortes (solo 'clip')")
|
|
347
|
+
parser.add_argument("--clips", type=int, metavar="N",
|
|
348
|
+
help="tope de recortes en esta corrida (solo 'clip')")
|
|
349
|
+
args = parser.parse_args()
|
|
350
|
+
try:
|
|
351
|
+
# 'clip' no toca la base: se carga solo la config
|
|
352
|
+
if args.command == "clip":
|
|
353
|
+
cmd_clip(Config(), args.name, args.out, args.clips)
|
|
354
|
+
return
|
|
355
|
+
cfg, db = _load()
|
|
356
|
+
if args.command == "mark":
|
|
357
|
+
cmd_mark(cfg, db, args.name)
|
|
358
|
+
else:
|
|
359
|
+
COMMANDS[args.command](cfg, db)
|
|
360
|
+
except KeyboardInterrupt:
|
|
361
|
+
print("\nInterrumpido.")
|
|
362
|
+
sys.exit(130)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
if __name__ == "__main__":
|
|
366
|
+
main()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# Configuración de aurclips
|
|
3
|
+
#
|
|
4
|
+
# El proyecto se afina en tres puntos del flujo, no parámetro a parámetro:
|
|
5
|
+
# 1. ARRIBA — cómo grabas (marks): grabar en beats y marcarlos le gana a
|
|
6
|
+
# cualquier heurística. Es la palanca más grande.
|
|
7
|
+
# 2. MEDIO — el selector (selection.profile): mismo motor, calibrado a
|
|
8
|
+
# cómo suenas tú.
|
|
9
|
+
# 3. ABAJO — título (channel/titles) y medición (`report`): ahí es donde
|
|
10
|
+
# el canal mejora con el tiempo.
|
|
11
|
+
# ============================================================
|
|
12
|
+
|
|
13
|
+
# ------------------------------------------------------------
|
|
14
|
+
# 1) ARRIBA — tus marcas al grabar
|
|
15
|
+
# Di la frase en voz alta mientras grabas, o deja un
|
|
16
|
+
# <video>.marks.txt al lado (lo escribe `aurclips mark`).
|
|
17
|
+
# ------------------------------------------------------------
|
|
18
|
+
marks:
|
|
19
|
+
enabled: true
|
|
20
|
+
phrases: # frases gatillo: dilas y ese momento queda marcado.
|
|
21
|
+
- "esto es un short" # ¿un fraseo tuyo no marcó? AGRÉGALO AQUÍ; es más
|
|
22
|
+
- "esto va a ser un short" # seguro que bajar 'similarity': una variante
|
|
23
|
+
- "esto va al short" # nueva coincide al 100% sin acercar el umbral a
|
|
24
|
+
- "este es el short" # los falsos positivos
|
|
25
|
+
- "va para short"
|
|
26
|
+
- "marca aquí"
|
|
27
|
+
- "clip esto"
|
|
28
|
+
similarity: 0.85 # se comparan por parecido, no al pie de la letra: si un
|
|
29
|
+
# día dices "esto es short" o Whisper escribe "shot", la
|
|
30
|
+
# marca no se pierde (1.0 = exigir la frase literal).
|
|
31
|
+
# Negar nunca marca: "esto NO es un short" se parece un
|
|
32
|
+
# 91% y aun así se descarta
|
|
33
|
+
exclusive: true # si el video trae marcas, solo se miran esas ventanas
|
|
34
|
+
# (false = el resto del video sigue compitiendo).
|
|
35
|
+
# OJO: un video SIN marcas no se ve afectado — se
|
|
36
|
+
# selecciona normal, no se queda sin Shorts
|
|
37
|
+
tolerance: 3.0 # holgura (seg) al emparejar una marca con una ventana
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------
|
|
40
|
+
# 3) ABAJO — tu canal: contexto para escribir los títulos
|
|
41
|
+
# ------------------------------------------------------------
|
|
42
|
+
channel:
|
|
43
|
+
angle: "" # de qué va tu canal, en una línea
|
|
44
|
+
# ej: "análisis tranquilo de videojuegos, sin gritos"
|
|
45
|
+
title_examples: [] # 3-5 títulos tuyos que te gusten: marcan la línea
|
|
46
|
+
# editorial que el modelo debe imitar
|
|
47
|
+
# - "Por qué nadie termina este juego"
|
|
48
|
+
# - "El detalle que todos se saltan"
|
|
49
|
+
|
|
50
|
+
# Canales de YouTube a vigilar (URL del canal o de su pestaña /videos)
|
|
51
|
+
channels:
|
|
52
|
+
# - "https://www.youtube.com/@MiCanal/videos"
|
|
53
|
+
|
|
54
|
+
# Cuántos videos recientes revisar por canal en cada corrida
|
|
55
|
+
channel_scan_limit: 5
|
|
56
|
+
|
|
57
|
+
# Duración mínima (segundos) para descargar un video de un canal
|
|
58
|
+
# (solo aplica a canales; la carpeta inbox acepta clips de cualquier duración)
|
|
59
|
+
min_source_duration: 300
|
|
60
|
+
|
|
61
|
+
# Calidad máxima de descarga
|
|
62
|
+
max_download_height: 1080
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------
|
|
65
|
+
# Transcripción (faster-whisper, corre local y gratis)
|
|
66
|
+
# ------------------------------------------------------------
|
|
67
|
+
whisper:
|
|
68
|
+
model: "medium" # tiny / base / small / medium / large-v3 (más grande = mejor)
|
|
69
|
+
# con GPU NVIDIA, "medium" o "large-v3" van rápido y transcriben mejor
|
|
70
|
+
language: null # null = autodetectar; o "es", "en", etc.
|
|
71
|
+
device: "auto" # auto = GPU si hay, si no CPU (con respaldo automático)
|
|
72
|
+
compute_type: "auto" # auto elige lo mejor según el dispositivo
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------
|
|
75
|
+
# 2) MEDIO — selección de clips (100% local, sin API keys)
|
|
76
|
+
# ------------------------------------------------------------
|
|
77
|
+
selection:
|
|
78
|
+
profile: "comentario" # calibración del selector a TU género:
|
|
79
|
+
# comentario = charla tranquila (análisis, podcast,
|
|
80
|
+
# tutorial): el volumen dice poco, manda
|
|
81
|
+
# cerrar la idea y la densidad
|
|
82
|
+
# gaming = reacciones y streams: los picos de
|
|
83
|
+
# audio sí señalan el momento
|
|
84
|
+
weights: {} # ajuste fino sobre el perfil; cada número es lo máximo
|
|
85
|
+
# que esa señal mueve la puntuación. Ej:
|
|
86
|
+
# energy: 0.08
|
|
87
|
+
# closes: 0.32
|
|
88
|
+
# señales: energy, pace, hook, punct, closes, density,
|
|
89
|
+
# filler, gaps, mark
|
|
90
|
+
clips_per_video: 3 # tope máximo de Shorts por video largo
|
|
91
|
+
minutes_per_short: 4 # densidad: ~1 Short por cada N minutos de video
|
|
92
|
+
# (el número real = duración/N, acotado por el tope;
|
|
93
|
+
# 0 = desactivado: usa siempre el tope)
|
|
94
|
+
quality_floor: 0.55 # descarta candidatos bajo esta fracción de la
|
|
95
|
+
# puntuación del mejor (0 = desactivado); mejor
|
|
96
|
+
# pocos Shorts buenos que rellenar el cupo
|
|
97
|
+
# (lo que marcaste tú queda exento)
|
|
98
|
+
#
|
|
99
|
+
# ESTE ES EL DIAL DEL VOLUMEN. Es agresivo: en una
|
|
100
|
+
# medición sintética dejó pasar 1 de 11 candidatos, o
|
|
101
|
+
# sea ~1 Short por video sin marcar. Si con corridas
|
|
102
|
+
# reales te rinde muy poco, BAJA ESTE NÚMERO (0.40),
|
|
103
|
+
# no toques los pesos: los pesos deciden CUÁL clip,
|
|
104
|
+
# este decide CUÁNTOS.
|
|
105
|
+
min_clip_seconds: 15
|
|
106
|
+
max_clip_seconds: 59
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------
|
|
109
|
+
# 3) ABAJO — títulos escritos por un LLM local y revisados por ti
|
|
110
|
+
# ------------------------------------------------------------
|
|
111
|
+
titles:
|
|
112
|
+
engine: "auto" # heuristic = título sacado del propio clip (sin LLM)
|
|
113
|
+
# ollama = siempre pedírselo a Ollama
|
|
114
|
+
# auto = usa Ollama si está corriendo
|
|
115
|
+
model: "qwen2.5:7b" # modelo de Ollama que redacta (alternativa ligera:
|
|
116
|
+
# "gemma3:4b"). Instálalo con: ollama pull qwen2.5:7b
|
|
117
|
+
url: "http://localhost:11434"
|
|
118
|
+
|
|
119
|
+
review:
|
|
120
|
+
enabled: true # nada se sube sin tu visto bueno: `aurclips review`
|
|
121
|
+
# muestra cada clip para aprobar, corregir o descartar
|
|
122
|
+
# (false = pipeline 100% desatendido)
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------
|
|
125
|
+
# Edición / render
|
|
126
|
+
# ------------------------------------------------------------
|
|
127
|
+
render:
|
|
128
|
+
subtitles: true # subtítulos virales quemados (estilo Hormozi)
|
|
129
|
+
words_per_caption: 3 # palabras por frase en pantalla (aparecen una a una)
|
|
130
|
+
font: "Anton" # viene con el paquete; si faltara, una familia genérica
|
|
131
|
+
font_size: 112 # sobre lienzo de 1080x1920 (rango viral: 80-120)
|
|
132
|
+
outline: 10 # grosor del contorno negro
|
|
133
|
+
base_color: "#FFFFFF"
|
|
134
|
+
highlight_colors: ["#FFD93D", "#39FF14"] # palabra clave (rota amarillo/verde)
|
|
135
|
+
caption_position: 0.70 # altura del texto (0.70 = tercio bajo, libre de UI)
|
|
136
|
+
tighten_silences: true # jump cuts: recorta pausas muertas
|
|
137
|
+
max_pause: 1.5 # pausas más largas que esto (seg) se recortan
|
|
138
|
+
# (1.0 para charla pura; 1.5-2.0 en gaming conserva acción visual)
|
|
139
|
+
crf: 20
|
|
140
|
+
preset: "veryfast"
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------
|
|
143
|
+
# Encuadre inteligente
|
|
144
|
+
# ------------------------------------------------------------
|
|
145
|
+
crop:
|
|
146
|
+
face_tracking: true # detecta el rostro y centra el recorte vertical en él
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------
|
|
149
|
+
# Filtro de contenido y duplicados
|
|
150
|
+
# ------------------------------------------------------------
|
|
151
|
+
safety:
|
|
152
|
+
enabled: true
|
|
153
|
+
action: "skip" # skip = descartar el clip | flag = guardarlo marcado para revisión
|
|
154
|
+
strict: false # false = permite groserías comunes (monetizables, normales en gaming)
|
|
155
|
+
# true = también filtra groserías (canales de marca/infantiles)
|
|
156
|
+
extra_words: [] # palabras adicionales a bloquear (además de la lista integrada)
|
|
157
|
+
|
|
158
|
+
dedup:
|
|
159
|
+
enabled: true
|
|
160
|
+
similarity: 0.8 # umbral de similitud (0-1) para considerar un clip duplicado
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------
|
|
163
|
+
# Límites por corrida (control de carga y escalado)
|
|
164
|
+
# ------------------------------------------------------------
|
|
165
|
+
limits:
|
|
166
|
+
max_videos_per_run: 3 # videos largos procesados por corrida
|
|
167
|
+
max_uploads_per_run: 5 # subidas por corrida (la cuota de YouTube da ~6/día)
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------
|
|
170
|
+
# Métricas y alertas
|
|
171
|
+
# ------------------------------------------------------------
|
|
172
|
+
stats:
|
|
173
|
+
enabled: true # consulta vistas/likes de los Shorts publicados
|
|
174
|
+
|
|
175
|
+
alerts:
|
|
176
|
+
discord_webhook: null # URL de webhook de Discord para avisos (opcional)
|
|
177
|
+
notify_on: ["error", "uploaded", "review"] # eventos que generan aviso
|
|
178
|
+
# review = hay clips esperando tu aprobación; sin
|
|
179
|
+
# este aviso la corrida de madrugada se queda muda
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------
|
|
182
|
+
# Publicación en YouTube
|
|
183
|
+
# ------------------------------------------------------------
|
|
184
|
+
upload:
|
|
185
|
+
enabled: false # MODO BETA: cambia a true cuando quieras publicar de verdad
|
|
186
|
+
privacy: "private" # se sube en privado y YouTube lo publica solo en publish_time
|
|
187
|
+
publish_time: "19:00" # hora local de publicación diaria
|
|
188
|
+
timezone: null # null = zona horaria del sistema; o "America/Mexico_City", etc.
|
|
189
|
+
min_lead_hours: 1 # margen mínimo entre subir y publicar
|
|
190
|
+
category_id: "24" # 24 = Entertainment, 20 = Gaming, 22 = People & Blogs
|
|
191
|
+
extra_tags: ["shorts"]
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------
|
|
194
|
+
# Rutas (relativas a la raíz del proyecto)
|
|
195
|
+
# ------------------------------------------------------------
|
|
196
|
+
paths:
|
|
197
|
+
data: "data"
|
|
198
|
+
inbox: "data/inbox" # deja aquí videos locales para procesar
|
|
199
|
+
downloads: "data/downloads"
|
|
200
|
+
work: "data/work"
|
|
201
|
+
output: "data/output"
|
|
202
|
+
credentials: "credentials" # client_secrets.json y token de YouTube
|
|
203
|
+
ffmpeg: "tools/ffmpeg/bin" # ffmpeg/ffprobe empaquetados (.exe en Windows);
|
|
204
|
+
# si están en el PATH, se usan esos
|
|
Binary file
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
http://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|