commit 55c7ccf316ec3b2f403eca99c13a66253d303504 Author: Leonardo Mortari Date: Thu Jul 31 19:29:14 2025 -0300 Init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb3571f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/videos +/outputs +/temp +/components/__pycache__ \ No newline at end of file diff --git a/Montserrat.ttf b/Montserrat.ttf new file mode 100644 index 0000000..5b4b5af Binary files /dev/null and b/Montserrat.ttf differ diff --git a/components/video.py b/components/video.py new file mode 100644 index 0000000..c47a895 --- /dev/null +++ b/components/video.py @@ -0,0 +1,141 @@ +import os +import subprocess +import unicodedata + +from moviepy.video.io.VideoFileClip import VideoFileClip +from moviepy.video.VideoClip import ColorClip +from moviepy.video.compositing.CompositeVideoClip import CompositeVideoClip +from moviepy import TextClip + +font = "./Montserrat.ttf" + +def normalize_filename(filename): + name = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode('ASCII') + return name.lower() + +def cut_video_new_clip(input_path: str, start: float, end: float, output_path: str): + video_codec = "libx264" + + with VideoFileClip(input_path) as clip: + segment = clip.subclipped(start, end) + fps = clip.fps or 30 + + segment.write_videofile( + output_path, + codec=video_codec, + audio=False, + remove_temp=True, + fps=fps, + ffmpeg_params=[ + "-preset", "ultrafast", + "-tune", "zerolatency", + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.1" + ] + ) + +def process_segment(input_path: str, top_text: str = "", bottom_text: str = "", filename="", idx=1) -> str: + os.makedirs("outputs", exist_ok=True) + os.makedirs(f"outputs/{filename}", exist_ok=True) + + final_width, final_height = 1080, 1920 + top_h, middle_h, bottom_h = 480, 960, 480 + + with VideoFileClip(input_path) as clip: + dur = clip.duration + bg = ColorClip(size=(final_width, final_height), color=(255, 255, 255), duration=dur) + video_resized = clip.resized(width=final_width) + y = top_h + (middle_h - video_resized.h) // 2 + video_resized = video_resized.with_position((0, y)) + video_codec = "libx264" + + txt_top = TextClip( + text=top_text, + font_size=70, + color="black", + font=font, + method="label", + size=(final_width, top_h) + ).with_duration(dur).with_position((0, 0)) + + txt_bot = TextClip( + text=bottom_text, + font_size=70, + color="black", + font=font, + method="label", + size=(final_width, bottom_h), + ).with_duration(dur).with_position((0, final_height - bottom_h)) + + final = CompositeVideoClip([bg, video_resized, txt_top, txt_bot], size=(final_width, final_height)) + + output_path = f"outputs/{filename}/clip_{idx}.mp4" + + final.write_videofile( + output_path, + codec=video_codec, + audio=False, + remove_temp=True, + ffmpeg_params=[ + "-preset", "ultrafast", + "-tune", "zerolatency", + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.1" + ] + ) + + final.close() + + return output_path + +def timestamp_to_seconds(ts): + if isinstance(ts, (int, float)): + return ts + + parts = ts.split(":") + parts = [float(p) for p in parts] + + if len(parts) == 3: + h, m, s = parts + return h * 3600 + m * 60 + s + elif len(parts) == 2: + m, s = parts + return m * 60 + s + elif len(parts) == 1: + return parts[0] + else: + raise ValueError(f"Timestamp inválido: {ts}") + +def process_full_video(filename: str, title: str, times: list = None) -> list: + os.makedirs("temp", exist_ok=True) + + times = times or [] + video_path = f"videos/{title}" + processed = [] + + video_codec = "libx264" + + print(f"Total de trechos: {len(times)}") + print(f"Codec de render: {video_codec}") + + for idx, interval in enumerate(times, start=1): + start = timestamp_to_seconds(interval.get("start", 0)) + end_raw = interval.get("end", None) + end = timestamp_to_seconds(end_raw) if end_raw is not None else None + top_text = interval.get("topText", "") + bottom_text = interval.get("bottomText", "") + + if end is None: + with VideoFileClip(video_path) as clip: + end = clip.duration + print(f"Cortando trecho {idx}: {start}s a {end}s") + + temp_path = f"temp/{os.path.splitext(filename)[0]}_{idx}.mp4" + cut_video_new_clip(video_path, start, end, temp_path) + + out = process_segment(temp_path, top_text, bottom_text, filename, idx) + processed.append(out) + + return processed diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0126067 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + video-render-api: + restart: unless-stopped + build: . + container_name: video-render-api + ports: + - "5000:5000" + volumes: + # - /home/well/outputs:/app/outputs + # - /home/well/videosDownload:/app/videos + # - /home/well/tempVideos:/app/temp + - ".:/app" # descomentar somente se for rodar no windows/linux local + # gpus: all + # environment: + # - NVIDIA_VISIBLE_DEVICES=all + # - NVIDIA_DRIVER_CAPABILITIES=compute,video,utility + command: "python -u main.py" + # runtime: nvidia + networks: + - dokploy-network + + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] +networks: + dokploy-network: + external: true diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..063d415 --- /dev/null +++ b/dockerfile @@ -0,0 +1,36 @@ +FROM python:3.11-slim + +WORKDIR /app + +EXPOSE 5000 + +ENV DEBIAN_FRONTEND=noninteractive + +COPY requirements.txt Montserrat.ttf ./ + +RUN apt-get update && \ + apt-get install -qq -y \ + build-essential \ + xvfb \ + xdg-utils \ + wget \ + unzip \ + ffmpeg \ + libpq-dev \ + vim \ + libmagick++-dev \ + imagemagick \ + fonts-liberation \ + sox \ + bc \ + gsfonts && \ + fc-cache -fv && \ + rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +VOLUME ["/app"] + +CMD ["python", "-u", "main.py"] diff --git a/main.py b/main.py new file mode 100644 index 0000000..4978d6d --- /dev/null +++ b/main.py @@ -0,0 +1,97 @@ +import os +import requests +import threading +import glob +import shutil + +os.environ["IMAGEIO_FFMPEG_EXE"] = "/usr/bin/ffmpeg" + +from flask import Flask, request, jsonify + +from components.video import process_full_video + +app = Flask(__name__) + +def process_and_call_webhook(url, video_id, times, webhook_url, row_number, filename, title): + try: + os.makedirs("videos", exist_ok=True) + os.makedirs("temp", exist_ok=True) + + print(f"Working on video {filename}") + processed_files = process_full_video(filename, title, times) + + payload = { + "videosProcessedQuantity": len(processed_files), + "filename": filename, + "processedFiles": processed_files, + "rowNumber": row_number, + "url": url, + "videoId": video_id, + "error": False, + } + + try: + resp = requests.post(webhook_url, json=payload, timeout=30) + + print(f"Webhook status: {resp.status_code}, content: {resp.text}") + + except Exception as webhook_error: + print(f"Erro ao chamar webhook: {webhook_error}") + + except Exception as e: + payload = { + "videosProcessedQuantity": 0, + "filename": filename, + "processedFiles": processed_files, + "rowNumber": row_number, + "url": url, + "videoId": video_id, + "error": str(e), + } + + try: + resp = requests.post(webhook_url, json=payload, timeout=30) + + print(f"Webhook send error status: {resp.status_code}") + + print(str(e)) + except Exception as webhook_error: + print(f"Erro ao chamar webhook: {webhook_error}") + + print(f"Erro no processamento: {e}") + +@app.route('/process', methods=['POST']) +def process_video(): + + data = request.get_json() + + if not data or not ("url" in data or "videoId" in data): + return jsonify({"error": "Informe 'url' ou 'videoId'"}), 400 + + url = data.get("url") + video_id = data.get("videoId") + title = data.get("title", "") + times = data.get("times", []) + webhook_url = data.get("webhookUrl") + row_number = data.get("rowNumber") + + if not webhook_url: + return jsonify({"error": "Informe 'webhookUrl'"}), 400 + + if not row_number: + return jsonify({"error": "Informe a linha da planilha"}), 400 + + filename = data.get("filename") + if not filename: + return jsonify({"error": "Informe 'filename'"}), 400 + + threading.Thread( + target=process_and_call_webhook, + args=(url, video_id, times, webhook_url, row_number, filename, title), + daemon=True + ).start() + + return jsonify({"message": f"{video_id if video_id else url}"}), 200 + +if __name__ == '__main__': + app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e16a219 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +flask +moviepy==2.2.0 +pillow==9.5.0 +yt_dlp +requests \ No newline at end of file