ElJoker63 commited on
Commit ·
ea02ff4
1
Parent(s): 0b0f00e
feat: soporte para marca de agua de imagen/logo y posiciones customizadas
Browse files- internal/ffmpeg/ffmpeg.go +68 -20
- internal/protocol/messages.go +10 -0
- main.go +43 -1
internal/ffmpeg/ffmpeg.go
CHANGED
|
@@ -224,36 +224,88 @@ func SanitizeSettings(s protocol.VideoSettings) protocol.VideoSettings {
|
|
| 224 |
return s
|
| 225 |
}
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
func Compress(
|
| 228 |
ctx context.Context,
|
| 229 |
inputPath, outputPath string,
|
| 230 |
settings protocol.VideoSettings,
|
|
|
|
| 231 |
totalDuration float64,
|
| 232 |
onProgress func(p protocol.TaskProgress),
|
| 233 |
) error {
|
| 234 |
settings = SanitizeSettings(settings)
|
| 235 |
|
| 236 |
-
var vfParts []string
|
| 237 |
-
vfParts = append(vfParts, BuildScaleFilter(settings.Resolution))
|
| 238 |
-
|
| 239 |
-
if settings.Watermark != "" {
|
| 240 |
-
fontPath := findFontFile()
|
| 241 |
-
if fontPath != "" {
|
| 242 |
-
vfParts = append(vfParts, fmt.Sprintf("drawtext=fontfile='%s':text='%s':x=w-tw-10:y=10:fontsize=20:fontcolor=white:box=1:boxcolor=black@0.4:boxborderw=4", escapeFontPath(fontPath), settings.Watermark))
|
| 243 |
-
} else {
|
| 244 |
-
vfParts = append(vfParts, fmt.Sprintf("drawtext=font='Sans':text='%s':x=w-tw-10:y=10:fontsize=20:fontcolor=white", settings.Watermark))
|
| 245 |
-
}
|
| 246 |
-
}
|
| 247 |
-
|
| 248 |
-
vfFilter := strings.Join(vfParts, ",")
|
| 249 |
-
|
| 250 |
args := []string{
|
| 251 |
"-y",
|
| 252 |
"-i", inputPath,
|
| 253 |
}
|
| 254 |
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
// Preservar subtítulos de texto si existen en el contenedor original (mkv/mp4)
|
| 259 |
textSubStreams := GetTextSubtitleStreams(ctx, inputPath)
|
|
@@ -264,10 +316,6 @@ func Compress(
|
|
| 264 |
args = append(args, "-c:s", "mov_text")
|
| 265 |
}
|
| 266 |
|
| 267 |
-
if vfFilter != "" {
|
| 268 |
-
args = append(args, "-vf", vfFilter)
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
codec := settings.Codec
|
| 272 |
if codec == "" {
|
| 273 |
codec = "libx264"
|
|
|
|
| 224 |
return s
|
| 225 |
}
|
| 226 |
|
| 227 |
+
// wmTextPosition devuelve las expresiones x/y de drawtext para la posición elegida
|
| 228 |
+
// ("tr" arriba-derecha, "tl" arriba-izquierda, "br" abajo-derecha, "bl" abajo-izquierda).
|
| 229 |
+
func wmTextPosition(pos string) (x, y string) {
|
| 230 |
+
switch strings.ToLower(strings.TrimSpace(pos)) {
|
| 231 |
+
case "tl":
|
| 232 |
+
return "10", "10"
|
| 233 |
+
case "br":
|
| 234 |
+
return "w-tw-10", "h-th-10"
|
| 235 |
+
case "bl":
|
| 236 |
+
return "10", "h-th-10"
|
| 237 |
+
default: // "tr" y cualquier valor vacío/desconocido
|
| 238 |
+
return "w-tw-10", "10"
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// wmOverlayExpr devuelve la expresión del filtro overlay para la marca de agua de imagen.
|
| 243 |
+
func wmOverlayExpr(pos string) string {
|
| 244 |
+
switch strings.ToLower(strings.TrimSpace(pos)) {
|
| 245 |
+
case "tl":
|
| 246 |
+
return "10:10"
|
| 247 |
+
case "br":
|
| 248 |
+
return "W-w-10:H-h-10"
|
| 249 |
+
case "bl":
|
| 250 |
+
return "10:H-h-10"
|
| 251 |
+
default:
|
| 252 |
+
return "W-w-10:10"
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// Compress ejecuta la compresión usando FFmpeg y reporta el progreso en vivo.
|
| 257 |
+
// wmImagePath es la ruta local a un logo de marca de agua (opcional); si se
|
| 258 |
+
// indica, tiene prioridad sobre la marca de agua de texto.
|
| 259 |
func Compress(
|
| 260 |
ctx context.Context,
|
| 261 |
inputPath, outputPath string,
|
| 262 |
settings protocol.VideoSettings,
|
| 263 |
+
wmImagePath string,
|
| 264 |
totalDuration float64,
|
| 265 |
onProgress func(p protocol.TaskProgress),
|
| 266 |
) error {
|
| 267 |
settings = SanitizeSettings(settings)
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
args := []string{
|
| 270 |
"-y",
|
| 271 |
"-i", inputPath,
|
| 272 |
}
|
| 273 |
|
| 274 |
+
useImageWM := wmImagePath != ""
|
| 275 |
+
if useImageWM {
|
| 276 |
+
args = append(args, "-i", wmImagePath)
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
if useImageWM {
|
| 280 |
+
// Cadena con filter_complex: escala/pad del video principal, logo
|
| 281 |
+
// escalado a máx. 150px de ancho y overlay en la posición elegida.
|
| 282 |
+
chain := "[0:v]" + BuildScaleFilter(settings.Resolution) + "[vbase];" +
|
| 283 |
+
"[1:v]scale='if(gt(iw,150),150,iw)':-1[wm];" +
|
| 284 |
+
"[vbase][wm]overlay=" + wmOverlayExpr(settings.WatermarkPos) + "[v]"
|
| 285 |
+
args = append(args,
|
| 286 |
+
"-filter_complex", chain,
|
| 287 |
+
"-map", "[v]", "-map", "0:a?",
|
| 288 |
+
)
|
| 289 |
+
} else {
|
| 290 |
+
var vfParts []string
|
| 291 |
+
vfParts = append(vfParts, BuildScaleFilter(settings.Resolution))
|
| 292 |
+
|
| 293 |
+
if settings.Watermark != "" {
|
| 294 |
+
x, y := wmTextPosition(settings.WatermarkPos)
|
| 295 |
+
fontPath := findFontFile()
|
| 296 |
+
if fontPath != "" {
|
| 297 |
+
vfParts = append(vfParts, fmt.Sprintf("drawtext=fontfile='%s':text='%s':x=%s:y=%s:fontsize=20:fontcolor=white:box=1:boxcolor=black@0.4:boxborderw=4", escapeFontPath(fontPath), settings.Watermark, x, y))
|
| 298 |
+
} else {
|
| 299 |
+
vfParts = append(vfParts, fmt.Sprintf("drawtext=font='Sans':text='%s':x=%s:y=%s:fontsize=20:fontcolor=white", settings.Watermark, x, y))
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
vfFilter := strings.Join(vfParts, ",")
|
| 304 |
+
args = append(args, "-map", "0:v:0", "-map", "0:a?")
|
| 305 |
+
if vfFilter != "" {
|
| 306 |
+
args = append(args, "-vf", vfFilter)
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
|
| 310 |
// Preservar subtítulos de texto si existen en el contenedor original (mkv/mp4)
|
| 311 |
textSubStreams := GetTextSubtitleStreams(ctx, inputPath)
|
|
|
|
| 316 |
args = append(args, "-c:s", "mov_text")
|
| 317 |
}
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
codec := settings.Codec
|
| 320 |
if codec == "" {
|
| 321 |
codec = "libx264"
|
internal/protocol/messages.go
CHANGED
|
@@ -24,6 +24,13 @@ type VideoSettings struct {
|
|
| 24 |
Preset string `json:"preset"`
|
| 25 |
Codec string `json:"codec"`
|
| 26 |
Watermark string `json:"watermark"`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
// WSMessage es el contenedor genérico para los mensajes que viajan por WebSocket
|
|
@@ -62,6 +69,9 @@ type TaskAssign struct {
|
|
| 62 |
DownloadURL string `json:"download_url"`
|
| 63 |
UploadURL string `json:"upload_url"`
|
| 64 |
Settings VideoSettings `json:"settings"`
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
// TaskProgress es el reporte en vivo que el Worker envía mientras FFmpeg procesa
|
|
|
|
| 24 |
Preset string `json:"preset"`
|
| 25 |
Codec string `json:"codec"`
|
| 26 |
Watermark string `json:"watermark"`
|
| 27 |
+
// WatermarkPos: posición de la marca de agua ("tr", "tl", "br", "bl"; vacío = tr)
|
| 28 |
+
WatermarkPos string `json:"watermark_pos,omitempty"`
|
| 29 |
+
// WatermarkImageFileID: referencia al logo en Telegram (modo imagen;
|
| 30 |
+
// si está presente, tiene prioridad sobre el texto)
|
| 31 |
+
WatermarkImageFileID string `json:"watermark_image_file_id,omitempty"`
|
| 32 |
+
// ThumbnailFileID: referencia a la miniatura personalizada en Telegram
|
| 33 |
+
ThumbnailFileID string `json:"thumbnail_file_id,omitempty"`
|
| 34 |
}
|
| 35 |
|
| 36 |
// WSMessage es el contenedor genérico para los mensajes que viajan por WebSocket
|
|
|
|
| 69 |
DownloadURL string `json:"download_url"`
|
| 70 |
UploadURL string `json:"upload_url"`
|
| 71 |
Settings VideoSettings `json:"settings"`
|
| 72 |
+
// WatermarkImageURL: de dónde descargar el logo si la marca de agua es imagen
|
| 73 |
+
// (vacío si la tarea no usa marca de imagen)
|
| 74 |
+
WatermarkImageURL string `json:"watermark_image_url,omitempty"`
|
| 75 |
}
|
| 76 |
|
| 77 |
// TaskProgress es el reporte en vivo que el Worker envía mientras FFmpeg procesa
|
main.go
CHANGED
|
@@ -454,6 +454,19 @@ func (w *Worker) executeTask(parentCtx context.Context, task protocol.TaskAssign
|
|
| 454 |
originalSize = inStat.Size()
|
| 455 |
}
|
| 456 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
// 2. Obtener duración del video con ffprobe
|
| 458 |
duration, err := ffmpeg.GetDuration(taskCtx, inputPath)
|
| 459 |
if err != nil {
|
|
@@ -465,7 +478,7 @@ func (w *Worker) executeTask(parentCtx context.Context, task protocol.TaskAssign
|
|
| 465 |
log.Printf("[%s] Iniciando compresión con FFmpeg (Duración: %.1fs)...", task.TaskID, duration)
|
| 466 |
startTime := time.Now()
|
| 467 |
|
| 468 |
-
err = ffmpeg.Compress(taskCtx, inputPath, outputPath, task.Settings, duration, func(p protocol.TaskProgress) {
|
| 469 |
p.TaskID = task.TaskID
|
| 470 |
_ = w.sendMessage(protocol.TypeTaskProgress, p)
|
| 471 |
})
|
|
@@ -560,6 +573,35 @@ func (w *Worker) executeTask(parentCtx context.Context, task protocol.TaskAssign
|
|
| 560 |
log.Printf("[%s] ✅ Tarea finalizada y confirmada.", task.TaskID)
|
| 561 |
}
|
| 562 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
func (w *Worker) reportFailed(taskID, errMsg string) {
|
| 564 |
_ = w.sendMessage(protocol.TypeTaskFailed, protocol.TaskFailed{
|
| 565 |
TaskID: taskID,
|
|
|
|
| 454 |
originalSize = inStat.Size()
|
| 455 |
}
|
| 456 |
|
| 457 |
+
// 1.5. Descargar el logo de marca de agua si la tarea incluye uno
|
| 458 |
+
wmImagePath := ""
|
| 459 |
+
if task.WatermarkImageURL != "" {
|
| 460 |
+
candidate := filepath.Join(w.workDir, fmt.Sprintf("%s_wm.png", task.TaskID))
|
| 461 |
+
if wmErr := w.downloadSimpleFile(taskCtx, w.resolveURL(task.WatermarkImageURL), candidate); wmErr != nil {
|
| 462 |
+
log.Printf("[%s] No se pudo descargar el logo (%v); se usará la marca de texto si existe", task.TaskID, wmErr)
|
| 463 |
+
_ = os.Remove(candidate)
|
| 464 |
+
} else {
|
| 465 |
+
wmImagePath = candidate
|
| 466 |
+
defer func() { _ = os.Remove(candidate) }()
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
// 2. Obtener duración del video con ffprobe
|
| 471 |
duration, err := ffmpeg.GetDuration(taskCtx, inputPath)
|
| 472 |
if err != nil {
|
|
|
|
| 478 |
log.Printf("[%s] Iniciando compresión con FFmpeg (Duración: %.1fs)...", task.TaskID, duration)
|
| 479 |
startTime := time.Now()
|
| 480 |
|
| 481 |
+
err = ffmpeg.Compress(taskCtx, inputPath, outputPath, task.Settings, wmImagePath, duration, func(p protocol.TaskProgress) {
|
| 482 |
p.TaskID = task.TaskID
|
| 483 |
_ = w.sendMessage(protocol.TypeTaskProgress, p)
|
| 484 |
})
|
|
|
|
| 573 |
log.Printf("[%s] ✅ Tarea finalizada y confirmada.", task.TaskID)
|
| 574 |
}
|
| 575 |
|
| 576 |
+
// downloadSimpleFile descarga un archivo pequeño del Manager (logo de marca de agua).
|
| 577 |
+
func (w *Worker) downloadSimpleFile(ctx context.Context, rawURL, destPath string) error {
|
| 578 |
+
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
| 579 |
+
if err != nil {
|
| 580 |
+
return err
|
| 581 |
+
}
|
| 582 |
+
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
| 583 |
+
req.Header.Set("X-Worker-Secret", w.secret)
|
| 584 |
+
|
| 585 |
+
resp, err := httpClient.Do(req)
|
| 586 |
+
if err != nil {
|
| 587 |
+
return err
|
| 588 |
+
}
|
| 589 |
+
defer resp.Body.Close()
|
| 590 |
+
|
| 591 |
+
if resp.StatusCode != http.StatusOK {
|
| 592 |
+
return fmt.Errorf("status %d", resp.StatusCode)
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
out, err := os.Create(destPath)
|
| 596 |
+
if err != nil {
|
| 597 |
+
return err
|
| 598 |
+
}
|
| 599 |
+
defer out.Close()
|
| 600 |
+
|
| 601 |
+
_, err = io.Copy(out, resp.Body)
|
| 602 |
+
return err
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
func (w *Worker) reportFailed(taskID, errMsg string) {
|
| 606 |
_ = w.sendMessage(protocol.TypeTaskFailed, protocol.TaskFailed{
|
| 607 |
TaskID: taskID,
|