Download loop_video.py from menochoppa/loop-studio: direct link, hf CLI and curl.
- Browser
- Download file 162 kB
-
https://huggingface.co/menochoppa/loop-studio/resolve/main/loop_video.py
- Command line
-
hf download hf://menochoppa/loop-studio/loop_video.py
-
curl -L -o loop_video.py https://huggingface.co/menochoppa/loop-studio/resolve/main/loop_video.py
162 kB
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import math | |
| import os | |
| import random | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import threading | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Callable, Optional | |
| try: | |
| import numpy as np | |
| except ImportError: # pragma: no cover - reported with a friendly message later | |
| np = None | |
| import tkinter as tk | |
| from tkinter import filedialog, messagebox, simpledialog, ttk | |
| try: | |
| from PIL import Image, ImageDraw, ImageFont, ImageTk | |
| except ImportError: # Preview falls back to Tk's native PNG support. | |
| Image = None | |
| ImageDraw = None | |
| ImageFont = None | |
| ImageTk = None | |
| try: | |
| from tkinterdnd2 import DND_FILES, TkinterDnD | |
| except ImportError: # Drag and drop is optional until requirements are installed. | |
| DND_FILES = None | |
| TkinterDnD = None | |
| FRAME_SIZE = 64 | |
| MAX_ANALYSIS_FRAMES = 2400 | |
| DEFAULT_TARGET_SECONDS = 20.0 | |
| DEFAULT_MIN_LOOP_SECONDS = 4.0 | |
| VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v", ".wmv", ".flv"} | |
| GIF_MODE_LABELS = { | |
| "nao criar GIF": "none", | |
| "todos": "all", | |
| "selecionados": "manual", | |
| "aleatorios": "random", | |
| } | |
| GIF_VARIANT_LABELS = { | |
| "sem marca": "plain", | |
| "com marca": "watermarked", | |
| "ambas": "both", | |
| } | |
| SCOPE_LABELS = {"todos": "all", "selecionados": "selected"} | |
| POSITION_LABELS = { | |
| "superior esquerdo": "top-left", | |
| "superior direito": "top-right", | |
| "inferior esquerdo": "bottom-left", | |
| "inferior direito": "bottom-right", | |
| "centro": "center", | |
| } | |
| StatusCallback = Optional[Callable[[str], None]] | |
| class VideoLoopError(RuntimeError): | |
| """An expected error while analyzing or rendering a video.""" | |
| class VideoInfo: | |
| duration: float | |
| width: int | |
| height: int | |
| class LoopCandidate: | |
| start: float | |
| end: float | |
| duration: float | |
| score: float | |
| class ProcessResult: | |
| input_path: Path | |
| output_path: Path | |
| source_duration: float | |
| output_duration: float | |
| candidate: LoopCandidate | |
| repeats: int | |
| class WatermarkConfig: | |
| mode: str | |
| text: str = "" | |
| image_path: Optional[Path] = None | |
| opacity: float = 0.80 | |
| font_size: int = 36 | |
| image_scale: int = 25 | |
| position: str = "bottom-right" | |
| x_percent: Optional[float] = None | |
| y_percent: Optional[float] = None | |
| class PipelineOptions: | |
| target_seconds: float | |
| min_loop_seconds: float | |
| prefix: str = "looping" | |
| create_plain: bool = True | |
| create_watermarked: bool = False | |
| watermark: Optional[WatermarkConfig] = None | |
| clean_metadata: bool = True | |
| gif_mode: str = "none" | |
| gif_count: int = 0 | |
| gif_variant: str = "plain" | |
| gif_fps: int = 24 | |
| gif_width: Optional[int] = None | |
| skip_existing: bool = True | |
| watermarks: tuple[WatermarkConfig, ...] = () | |
| class PipelineReport: | |
| output_folder: Path | |
| loop_folder: Path | |
| gif_folder: Path | |
| outputs: tuple[Path, ...] | |
| gifs: tuple[Path, ...] | |
| failures: tuple[tuple[Path, str], ...] | |
| skipped: tuple[Path, ...] | |
| cancelled: bool | |
| created_videos: tuple[Path, ...] = () | |
| created_gifs: tuple[Path, ...] = () | |
| def _require_numpy() -> None: | |
| if np is None: | |
| raise VideoLoopError( | |
| "A biblioteca NumPy nao esta instalada. Execute: python -m pip install numpy" | |
| ) | |
| def _find_executable(name: str) -> str: | |
| executable = shutil.which(name) | |
| if executable is not None: | |
| return executable | |
| local = Path(os.environ.get("LOCALAPPDATA", "")) | |
| candidates = [] | |
| links = local / "Microsoft" / "WinGet" / "Links" | |
| if links.is_dir(): | |
| candidates.extend(links.glob(f"{name}*.exe")) | |
| packages = local / "Microsoft" / "WinGet" / "Packages" | |
| if packages.is_dir(): | |
| candidates.extend(packages.glob(f"*{name}*/**/{name}.exe")) | |
| for candidate in candidates: | |
| if candidate.is_file(): | |
| return str(candidate) | |
| raise VideoLoopError( | |
| f"Nao encontrei {name}. Instale o FFmpeg e adicione a pasta bin ao PATH." | |
| ) | |
| def _run_checked(command: list[str]) -> None: | |
| result = subprocess.run( | |
| command, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| encoding="utf-8", | |
| errors="replace", | |
| ) | |
| if result.returncode != 0: | |
| details = result.stderr.strip() | |
| if len(details) > 1800: | |
| details = details[-1800:] | |
| raise VideoLoopError(details or "O FFmpeg nao conseguiu processar o video.") | |
| def _as_float(value: object) -> Optional[float]: | |
| if value is None or value == "N/A": | |
| return None | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return None | |
| def probe_video(path: Path) -> VideoInfo: | |
| """Read the basic video metadata using ffprobe.""" | |
| if not path.is_file(): | |
| raise VideoLoopError(f"Arquivo de entrada nao encontrado: {path}") | |
| ffprobe = _find_executable("ffprobe") | |
| command = [ | |
| ffprobe, | |
| "-v", | |
| "error", | |
| "-select_streams", | |
| "v:0", | |
| "-show_entries", | |
| "stream=width,height,duration:format=duration", | |
| "-of", | |
| "json", | |
| str(path), | |
| ] | |
| result = subprocess.run( | |
| command, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| encoding="utf-8", | |
| errors="replace", | |
| ) | |
| if result.returncode != 0: | |
| details = result.stderr.strip() | |
| raise VideoLoopError(details or "Nao foi possivel ler os dados do video.") | |
| try: | |
| metadata = json.loads(result.stdout) | |
| stream = metadata["streams"][0] | |
| except (KeyError, IndexError, json.JSONDecodeError) as exc: | |
| raise VideoLoopError("O arquivo nao contem um fluxo de video valido.") from exc | |
| duration = _as_float(stream.get("duration")) | |
| if duration is None: | |
| duration = _as_float(metadata.get("format", {}).get("duration")) | |
| if duration is None or duration <= 0: | |
| raise VideoLoopError("Nao foi possivel descobrir a duracao do video.") | |
| try: | |
| width = int(stream.get("width") or 0) | |
| height = int(stream.get("height") or 0) | |
| except (TypeError, ValueError): | |
| width, height = 0, 0 | |
| return VideoInfo(duration=duration, width=width, height=height) | |
| def analysis_fps(duration: float) -> float: | |
| """Choose enough samples for short videos without exploding on long ones.""" | |
| return max(1.0, min(10.0, MAX_ANALYSIS_FRAMES / max(duration, 0.1))) | |
| def sample_frames(path: Path, fps: float) -> np.ndarray: | |
| """Decode small, fixed-size RGB frames for the loop search.""" | |
| _require_numpy() | |
| ffmpeg = _find_executable("ffmpeg") | |
| video_filter = ( | |
| f"fps={fps:.6f}," | |
| f"scale={FRAME_SIZE}:{FRAME_SIZE}:force_original_aspect_ratio=decrease," | |
| f"pad={FRAME_SIZE}:{FRAME_SIZE}:(ow-iw)/2:(oh-ih)/2:color=black," | |
| "format=rgb24" | |
| ) | |
| command = [ | |
| ffmpeg, | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-i", | |
| str(path), | |
| "-vf", | |
| video_filter, | |
| "-frames:v", | |
| str(MAX_ANALYSIS_FRAMES), | |
| "-f", | |
| "rawvideo", | |
| "-pix_fmt", | |
| "rgb24", | |
| "pipe:1", | |
| ] | |
| result = subprocess.run( | |
| command, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| ) | |
| if result.returncode != 0: | |
| details = result.stderr.decode("utf-8", errors="replace").strip() | |
| raise VideoLoopError(details or "Nao foi possivel extrair quadros do video.") | |
| bytes_per_frame = FRAME_SIZE * FRAME_SIZE * 3 | |
| frame_count = len(result.stdout) // bytes_per_frame | |
| if frame_count < 2: | |
| raise VideoLoopError("O video tem poucos quadros para encontrar um loop.") | |
| usable = result.stdout[: frame_count * bytes_per_frame] | |
| return np.frombuffer(usable, dtype=np.uint8).reshape( | |
| (frame_count, FRAME_SIZE, FRAME_SIZE, 3) | |
| ).copy() | |
| def _frame_features(frames: np.ndarray) -> np.ndarray: | |
| """Create compact features that tolerate small brightness changes.""" | |
| rgb = frames.astype(np.float32) / 255.0 | |
| gray = rgb @ np.array([0.299, 0.587, 0.114], dtype=np.float32) | |
| # Average 64x64 frames into an 8x8 image, then normalize each frame. | |
| pooled = gray.reshape((-1, 8, 8, 8, 8)).mean(axis=(2, 4)) | |
| mean = pooled.mean(axis=(1, 2), keepdims=True) | |
| spread = pooled.std(axis=(1, 2), keepdims=True) | |
| normalized = np.clip((pooled - mean) / (spread + 0.05), -3.0, 3.0) / 3.0 | |
| # A little color information prevents different colored scenes from matching. | |
| color_mean = rgb.mean(axis=(1, 2)) * 0.25 | |
| return np.concatenate((normalized.reshape((len(frames), -1)), color_mean), axis=1) | |
| def _fallback_candidate(source_duration: float) -> LoopCandidate: | |
| return LoopCandidate( | |
| start=0.0, | |
| end=source_duration, | |
| duration=source_duration, | |
| score=1.0, | |
| ) | |
| def find_best_loop( | |
| frames: np.ndarray, | |
| source_duration: float, | |
| min_loop_seconds: float, | |
| sample_fps: float, | |
| ) -> LoopCandidate: | |
| """Find the lowest-mismatch repeated section in sampled frames. | |
| A candidate compares a short sequence at its start with the sequence immediately | |
| after its end. This checks both the picture and the direction of movement, rather | |
| than trusting only one pair of nearly identical frames. | |
| """ | |
| _require_numpy() | |
| if min_loop_seconds <= 0: | |
| raise VideoLoopError("A duracao minima do loop deve ser maior que zero.") | |
| if min_loop_seconds > source_duration: | |
| min_loop_seconds = source_duration | |
| frame_count = len(frames) | |
| minimum_frames = max(1, math.ceil(min_loop_seconds * sample_fps - 1e-9)) | |
| if frame_count <= minimum_frames + 1: | |
| return _fallback_candidate(source_duration) | |
| features = _frame_features(frames) | |
| frame_activity = np.mean(np.abs(np.diff(features, axis=0)), axis=1) | |
| global_activity = float(np.mean(frame_activity)) if len(frame_activity) else 0.0 | |
| def static_penalty(start_index: int, end_index: int) -> float: | |
| if global_activity <= 1e-4: | |
| return 0.0 | |
| values = frame_activity[start_index : min(end_index, len(frame_activity))] | |
| if len(values) == 0: | |
| return 0.0 | |
| ratio = float(np.mean(values)) / global_activity | |
| if ratio < 0.15: | |
| return 0.75 | |
| if ratio < 0.35: | |
| return 0.20 | |
| return 0.0 | |
| window_size = max(2, min(6, round(sample_fps * 0.4))) | |
| window_size = min(window_size, frame_count - minimum_frames) | |
| if window_size < 2: | |
| return _fallback_candidate(source_duration) | |
| window_count = frame_count - window_size + 1 | |
| windows = np.stack( | |
| [features[index : index + window_size].reshape(-1) for index in range(window_count)] | |
| ) | |
| motion_windows = np.diff( | |
| windows.reshape((window_count, window_size, -1)), axis=1 | |
| ).reshape((window_count, -1)) | |
| max_start = window_count - 1 - minimum_frames | |
| max_end = window_count - 1 | |
| if max_start < 0: | |
| return _fallback_candidate(source_duration) | |
| best_score = float("inf") | |
| best_start = 0 | |
| best_end = minimum_frames | |
| # A source may contain exactly one cycle, so there is no frame after the | |
| # end to compare with the beginning. In that case compare the closing | |
| # frame and its motion with the opening boundary. | |
| last_frame = features[-1] | |
| last_motion = features[-1] - features[-2] | |
| for start_index in range(frame_count - minimum_frames + 1): | |
| if start_index + 1 >= frame_count: | |
| continue | |
| boundary_distance = float( | |
| np.mean(np.abs(last_frame - features[start_index])) | |
| ) | |
| motion_distance = float( | |
| np.mean( | |
| np.abs(last_motion - (features[start_index + 1] - features[start_index])) | |
| ) | |
| ) | |
| duration = (frame_count - start_index) / sample_fps | |
| duration_penalty = 0.02 * max(0.0, duration - min_loop_seconds) / max( | |
| min_loop_seconds, 1.0 | |
| ) | |
| score = ( | |
| boundary_distance * 0.70 | |
| + motion_distance * 0.30 | |
| + duration_penalty | |
| + static_penalty(start_index, frame_count) | |
| ) | |
| if score < best_score: | |
| best_score = score | |
| best_start = start_index | |
| best_end = frame_count | |
| for start_index in range(max_start + 1): | |
| end_indices = np.arange(start_index + minimum_frames, max_end + 1) | |
| sequence_distance = np.mean( | |
| np.abs(windows[end_indices] - windows[start_index]), axis=1 | |
| ) | |
| boundary_distance = np.mean( | |
| np.abs(features[end_indices] - features[start_index]), axis=1 | |
| ) | |
| motion_distance = np.mean( | |
| np.abs(motion_windows[end_indices] - motion_windows[start_index]), axis=1 | |
| ) | |
| durations = (end_indices - start_index) / sample_fps | |
| # Prefer the shortest valid loop only when visual quality is comparable. | |
| duration_penalty = 0.02 * np.maximum( | |
| 0.0, durations - min_loop_seconds | |
| ) / max(min_loop_seconds, 1.0) | |
| activity_penalty = np.array( | |
| [static_penalty(start_index, int(end)) for end in end_indices], | |
| dtype=np.float32, | |
| ) | |
| scores = ( | |
| sequence_distance * 0.60 | |
| + boundary_distance * 0.20 | |
| + motion_distance * 0.20 | |
| + duration_penalty | |
| + activity_penalty | |
| ) | |
| local_index = int(np.argmin(scores)) | |
| local_score = float(scores[local_index]) | |
| if local_score < best_score: | |
| best_score = local_score | |
| best_start = start_index | |
| best_end = int(end_indices[local_index]) | |
| start = best_start / sample_fps | |
| end = min(best_end / sample_fps, source_duration) | |
| duration = end - start | |
| if duration < min_loop_seconds - 0.05: | |
| return _fallback_candidate(source_duration) | |
| return LoopCandidate( | |
| start=start, | |
| end=end, | |
| duration=duration, | |
| score=best_score, | |
| ) | |
| def analyze_video( | |
| input_path: Path, | |
| min_loop_seconds: float, | |
| status: StatusCallback = None, | |
| ) -> tuple[VideoInfo, LoopCandidate, float]: | |
| info = probe_video(input_path) | |
| if min_loop_seconds <= 0: | |
| raise VideoLoopError("A duracao minima do loop deve ser maior que zero.") | |
| if min_loop_seconds > info.duration + 0.05: | |
| if status: | |
| status( | |
| f"Video ({info.duration:.2f}s) mais curto que o loop minimo " | |
| f"({min_loop_seconds:.2f}s); usarei o video inteiro como loop." | |
| ) | |
| min_loop_seconds = info.duration | |
| fps = analysis_fps(info.duration) | |
| if status: | |
| status(f"Analisando quadros em {fps:.1f} fps...") | |
| frames = sample_frames(input_path, fps) | |
| candidate = find_best_loop(frames, info.duration, min_loop_seconds, fps) | |
| return info, candidate, fps | |
| def repetition_count(loop_duration: float, target_seconds: float) -> int: | |
| if not math.isfinite(loop_duration) or loop_duration <= 0: | |
| raise VideoLoopError("O loop encontrado tem duracao invalida.") | |
| if not math.isfinite(target_seconds) or target_seconds <= 0: | |
| raise VideoLoopError("A duracao desejada deve ser maior que zero.") | |
| return max(1, math.ceil(target_seconds / loop_duration - 1e-9)) | |
| def _is_mp4_family(path: Path) -> bool: | |
| return path.suffix.lower() in {".mp4", ".m4v", ".mov"} | |
| def render_loop( | |
| input_path: Path, | |
| output_path: Path, | |
| candidate: LoopCandidate, | |
| target_seconds: float, | |
| status: StatusCallback = None, | |
| ) -> tuple[int, float]: | |
| """Render a measured segment repeatedly with normalized timestamps. | |
| The old implementation used stream copying with ``-stream_loop``. That can | |
| leave audio/timestamps longer than the video and make players freeze the | |
| last frame. Repeated entries are now concatenated and reencoded, so every | |
| repetition has fresh, monotonic timestamps. | |
| """ | |
| if output_path.resolve() == input_path.resolve(): | |
| raise VideoLoopError("Escolha um arquivo de saida diferente do arquivo original.") | |
| if not output_path.parent.exists(): | |
| raise VideoLoopError(f"A pasta de saida nao existe: {output_path.parent}") | |
| ffmpeg = _find_executable("ffmpeg") | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| with tempfile.TemporaryDirectory(prefix="video_loop_") as temporary_directory: | |
| temporary_root = Path(temporary_directory) | |
| segment_path = temporary_root / "segment.mp4" | |
| extract_command = [ | |
| ffmpeg, | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-i", | |
| str(input_path), | |
| "-ss", | |
| f"{candidate.start:.6f}", | |
| "-t", | |
| f"{candidate.duration:.6f}", | |
| "-map", | |
| "0:v:0", | |
| "-map", | |
| "0:a:0?", | |
| "-vf", | |
| "setpts=PTS-STARTPTS", | |
| "-af", | |
| "asetpts=PTS-STARTPTS", | |
| "-c:v", | |
| "libx264", | |
| "-preset", | |
| "medium", | |
| "-crf", | |
| "18", | |
| "-pix_fmt", | |
| "yuv420p", | |
| "-c:a", | |
| "aac", | |
| "-b:a", | |
| "192k", | |
| "-avoid_negative_ts", | |
| "make_zero", | |
| str(segment_path), | |
| ] | |
| _run_checked(extract_command) | |
| segment_duration = probe_video(segment_path).duration | |
| repeats = repetition_count(segment_duration, target_seconds) | |
| if status: | |
| status( | |
| f"Trecho real de {segment_duration:.3f}s; " | |
| f"montando {repeats} repeticoes completas..." | |
| ) | |
| concat_list = temporary_root / "concat.txt" | |
| concat_line = str(segment_path.resolve()).replace("\\", "/").replace("'", "'\\''") | |
| concat_list.write_text( | |
| "".join(f"file '{concat_line}'\n" for _ in range(repeats)), | |
| encoding="utf-8", | |
| ) | |
| loop_command = [ | |
| ffmpeg, | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-f", | |
| "concat", | |
| "-safe", | |
| "0", | |
| "-i", | |
| str(concat_list), | |
| "-map", | |
| "0:v:0", | |
| "-map", | |
| "0:a:0?", | |
| "-vf", | |
| "setpts=PTS-STARTPTS", | |
| "-af", | |
| "asetpts=PTS-STARTPTS", | |
| "-shortest", | |
| "-avoid_negative_ts", | |
| "make_zero", | |
| "-c:v", | |
| "libx264", | |
| "-preset", | |
| "medium", | |
| "-crf", | |
| "18", | |
| "-pix_fmt", | |
| "yuv420p", | |
| "-c:a", | |
| "aac", | |
| "-b:a", | |
| "192k", | |
| ] | |
| if _is_mp4_family(output_path): | |
| loop_command.extend(["-movflags", "+faststart"]) | |
| loop_command.append(str(output_path)) | |
| _run_checked(loop_command) | |
| output_duration = probe_video(output_path).duration | |
| if output_duration + max(0.05, segment_duration / 20) < target_seconds: | |
| raise VideoLoopError( | |
| f"A saida ficou menor que o alvo ({output_duration:.2f}s < {target_seconds:.2f}s)." | |
| ) | |
| return repeats, output_duration | |
| def process_video( | |
| input_path: Path, | |
| output_path: Path, | |
| target_seconds: float, | |
| min_loop_seconds: float, | |
| status: StatusCallback = None, | |
| ) -> ProcessResult: | |
| info, candidate, _ = analyze_video(input_path, min_loop_seconds, status) | |
| if status: | |
| status( | |
| f"Loop escolhido: {candidate.start:.2f}s ate {candidate.end:.2f}s " | |
| f"({candidate.duration:.2f}s)." | |
| ) | |
| repeats, output_duration = render_loop( | |
| input_path, | |
| output_path, | |
| candidate, | |
| target_seconds, | |
| status, | |
| ) | |
| return ProcessResult( | |
| input_path=input_path, | |
| output_path=output_path, | |
| source_duration=info.duration, | |
| output_duration=output_duration, | |
| candidate=candidate, | |
| repeats=repeats, | |
| ) | |
| def default_output_path(input_path: Path) -> Path: | |
| return input_path.with_name(f"{input_path.stem}_looped.mp4") | |
| def is_video_path(path: Path) -> bool: | |
| return path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS | |
| def _valid_generated_output(path: Path) -> bool: | |
| try: | |
| if not path.is_file() or path.stat().st_size < 1024: | |
| return False | |
| if path.suffix.lower() == ".gif": | |
| return True | |
| return probe_video(path).duration > 0.05 | |
| except (OSError, VideoLoopError): | |
| return False | |
| def video_files_in_folder(folder: Path) -> list[Path]: | |
| """Collect videos recursively while ignoring generated looping folders.""" | |
| if not folder.is_dir(): | |
| return [] | |
| files = [] | |
| for path in folder.rglob("*"): | |
| if not is_video_path(path): | |
| continue | |
| if any(part.casefold() == "looping" for part in path.parts): | |
| continue | |
| files.append(path) | |
| return sorted(files, key=lambda path: str(path).casefold()) | |
| def unique_video_paths(paths: list[Path]) -> list[Path]: | |
| result = [] | |
| seen = set() | |
| for path in paths: | |
| if not is_video_path(path): | |
| continue | |
| resolved = str(path.resolve()).casefold() | |
| if resolved in seen: | |
| continue | |
| seen.add(resolved) | |
| result.append(path) | |
| return result | |
| def sanitize_prefix(prefix: str) -> str: | |
| cleaned = re.sub(r"[^\w\-]+", "_", prefix.strip(), flags=re.UNICODE) | |
| return cleaned.strip("_") or "looping" | |
| def _filter_path(path: Path) -> str: | |
| value = path.resolve().as_posix() | |
| for character in (":", "'", ",", "[", "]"): | |
| value = value.replace(character, "\\" + character) | |
| return value | |
| def _position_expressions(position: str, overlay: bool = False) -> tuple[str, str]: | |
| margin = "24" | |
| if overlay: | |
| positions = { | |
| "top-left": (margin, margin), | |
| "top-right": (f"main_w-overlay_w-{margin}", margin), | |
| "bottom-left": (margin, f"main_h-overlay_h-{margin}"), | |
| "bottom-right": ( | |
| f"main_w-overlay_w-{margin}", | |
| f"main_h-overlay_h-{margin}", | |
| ), | |
| "center": ("(main_w-overlay_w)/2", "(main_h-overlay_h)/2"), | |
| } | |
| else: | |
| positions = { | |
| "top-left": (margin, margin), | |
| "top-right": (f"w-tw-{margin}", margin), | |
| "bottom-left": (margin, f"h-th-{margin}"), | |
| "bottom-right": (f"w-tw-{margin}", f"h-th-{margin}"), | |
| "center": ("(w-tw)/2", "(h-th)/2"), | |
| } | |
| return positions.get(position, positions["bottom-right"]) | |
| def _default_font_file() -> Optional[Path]: | |
| windows_fonts = Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" | |
| candidates = ( | |
| windows_fonts / "arial.ttf", | |
| windows_fonts / "segoeui.ttf", | |
| Path("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), | |
| Path("/usr/share/fonts/dejavu/DejaVuSans.ttf"), | |
| ) | |
| return next((path for path in candidates if path.is_file()), None) | |
| def _video_output_args(output_path: Path) -> list[str]: | |
| args = [ | |
| "-map_metadata", | |
| "-1", | |
| "-map_metadata:s", | |
| "-1", | |
| "-map_chapters", | |
| "-1", | |
| "-c:v", | |
| "libx264", | |
| "-preset", | |
| "medium", | |
| "-crf", | |
| "18", | |
| "-pix_fmt", | |
| "yuv420p", | |
| "-c:a", | |
| "aac", | |
| "-b:a", | |
| "192k", | |
| ] | |
| if _is_mp4_family(output_path): | |
| args.extend(["-movflags", "+faststart"]) | |
| return args | |
| def strip_metadata(path: Path) -> None: | |
| """Remove container metadata without changing the encoded streams.""" | |
| ffmpeg = _find_executable("ffmpeg") | |
| temporary = path.with_name(f".{path.stem}.metadata_tmp{path.suffix}") | |
| temporary.unlink(missing_ok=True) | |
| command = [ | |
| ffmpeg, | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-i", | |
| str(path), | |
| "-map", | |
| "0", | |
| "-map_metadata", | |
| "-1", | |
| "-map_metadata:s", | |
| "-1", | |
| "-map_chapters", | |
| "-1", | |
| "-c", | |
| "copy", | |
| "-fflags", | |
| "+bitexact", | |
| str(temporary), | |
| ] | |
| try: | |
| _run_checked(command) | |
| os.replace(temporary, path) | |
| finally: | |
| temporary.unlink(missing_ok=True) | |
| def _validate_watermark(config: Optional[WatermarkConfig]) -> WatermarkConfig: | |
| if config is None: | |
| raise VideoLoopError("Configure uma marca d'agua ou desative a versao com marca.") | |
| if config.mode == "text" and not config.text.strip(): | |
| raise VideoLoopError("Digite o texto da marca d'agua.") | |
| if config.mode == "image": | |
| if config.image_path is None or not config.image_path.is_file(): | |
| raise VideoLoopError("Escolha uma imagem valida para a marca d'agua.") | |
| if not 0.05 <= config.opacity <= 1.0: | |
| raise VideoLoopError("A opacidade deve ficar entre 0.05 e 1.0.") | |
| if config.font_size < 8 or config.font_size > 500: | |
| raise VideoLoopError("O tamanho do texto deve ficar entre 8 e 500.") | |
| if config.image_scale < 1 or config.image_scale > 100: | |
| raise VideoLoopError("A escala da imagem deve ficar entre 1 e 100%.") | |
| for name, value in (("X", config.x_percent), ("Y", config.y_percent)): | |
| if value is not None and not 0.0 <= value <= 100.0: | |
| raise VideoLoopError(f"A posicao {name} da marca d'agua deve ficar entre 0 e 100%.") | |
| return config | |
| def _clamped_percent(value: Optional[float]) -> Optional[float]: | |
| if value is None: | |
| return None | |
| return min(100.0, max(0.0, value)) | |
| def _percent_x_expression(percent: float, anchor: str) -> str: | |
| return f"(w*{percent / 100:.4f}-{anchor}w/2)" | |
| def _percent_y_expression(percent: float, anchor: str) -> str: | |
| return f"(h*{percent / 100:.4f}-{anchor}h/2)" | |
| def _text_watermark_filter(config: WatermarkConfig, text_file: Path) -> str: | |
| x, y = _position_expressions(config.position) | |
| if (percent := _clamped_percent(config.x_percent)) is not None: | |
| x = _percent_x_expression(percent, "t") | |
| if (percent := _clamped_percent(config.y_percent)) is not None: | |
| y = _percent_y_expression(percent, "t") | |
| font_file = _default_font_file() | |
| font_part = f"fontfile='{_filter_path(font_file)}':" if font_file else "" | |
| return ( | |
| f"drawtext={font_part}textfile='{_filter_path(text_file)}':" | |
| f"fontcolor=white@{config.opacity:.3f}:fontsize={config.font_size}:" | |
| f"borderw=2:bordercolor=black@0.55:shadowx=1:shadowy=1:x={x}:y={y}" | |
| ) | |
| def _image_overlay_expressions(config: WatermarkConfig) -> tuple[str, str]: | |
| x, y = _position_expressions(config.position, overlay=True) | |
| if (percent := _clamped_percent(config.x_percent)) is not None: | |
| x = f"(main_w*{percent / 100:.4f}-overlay_w/2)" | |
| if (percent := _clamped_percent(config.y_percent)) is not None: | |
| y = f"(main_h*{percent / 100:.4f}-overlay_h/2)" | |
| return x, y | |
| def _image_watermark_filter(config: WatermarkConfig) -> str: | |
| if config.image_path is None: | |
| raise VideoLoopError("Imagem de marca d'agua ausente.") | |
| x, y = _image_overlay_expressions(config) | |
| scale = config.image_scale / 100.0 | |
| return ( | |
| f"[1:v]format=rgba,scale=trunc(iw*{scale:.4f}/2)*2:-1," | |
| f"colorchannelmixer=aa={config.opacity:.3f}[watermark];" | |
| f"[0:v][watermark]overlay=x={x}:y={y}:eof_action=repeat:shortest=1[out]" | |
| ) | |
| def _watermark_chain( | |
| configs: tuple[WatermarkConfig, ...], | |
| text_directory: Path, | |
| ) -> tuple[str, tuple[Path, ...], str]: | |
| """Build one filter chain that applies every watermark layer in a single pass.""" | |
| filters: list[str] = [] | |
| image_inputs: list[Path] = [] | |
| current = "[0:v]" | |
| for index, config in enumerate(configs): | |
| output_label = f"[wm_{index}]" | |
| if config.mode == "text": | |
| text_file = text_directory / f"text_{index}.txt" | |
| text_file.write_text(config.text, encoding="utf-8") | |
| filters.append( | |
| f"{current}{_text_watermark_filter(config, text_file)}{output_label}" | |
| ) | |
| else: | |
| if config.image_path is None: | |
| raise VideoLoopError("Imagem de marca d'agua ausente.") | |
| input_index = len(image_inputs) + 1 | |
| image_inputs.append(config.image_path) | |
| x, y = _image_overlay_expressions(config) | |
| scale = config.image_scale / 100.0 | |
| image_label = f"[watermark_{index}]" | |
| filters.append( | |
| f"[{input_index}:v]format=rgba," | |
| f"scale=trunc(iw*{scale:.4f}/2)*2:-1," | |
| f"colorchannelmixer=aa={config.opacity:.3f}{image_label}" | |
| ) | |
| # shortest=1 ends the overlay exactly with the video; without it the | |
| # infinite image input keeps the encoder alive and freezes the tail. | |
| filters.append( | |
| f"{current}{image_label}overlay=x={x}:y={y}:" | |
| f"eof_action=repeat:shortest=1{output_label}" | |
| ) | |
| current = output_label | |
| return ";".join(filters), tuple(image_inputs), current | |
| def apply_watermarks( | |
| input_path: Path, | |
| output_path: Path, | |
| configs: tuple[WatermarkConfig, ...], | |
| ) -> None: | |
| """Apply every watermark layer in a single encoded pass. | |
| The previous version rendered one layer per pass and relied on ``-shortest`` | |
| with an infinite image input. FFmpeg could then keep encoding after the | |
| video ended, producing a frozen tail (video longer than audio). The chain | |
| here terminates with the main video and the duration is validated. | |
| """ | |
| if not configs: | |
| raise VideoLoopError("Nenhuma marca d'agua foi configurada.") | |
| configs = tuple(_validate_watermark(config) for config in configs) | |
| info = probe_video(input_path) | |
| if info.duration <= 0: | |
| raise VideoLoopError("Nao foi possivel medir a duracao do video.") | |
| ffmpeg = _find_executable("ffmpeg") | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| with tempfile.TemporaryDirectory(prefix="watermark_") as temporary_directory: | |
| filters, image_inputs, final_label = _watermark_chain( | |
| configs, Path(temporary_directory) | |
| ) | |
| command = [ | |
| ffmpeg, | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-i", | |
| str(input_path), | |
| ] | |
| for image_path in image_inputs: | |
| command.extend(["-loop", "1", "-i", str(image_path)]) | |
| command.extend( | |
| [ | |
| "-filter_complex", | |
| filters, | |
| "-map", | |
| final_label, | |
| "-map", | |
| "0:a:0?", | |
| "-t", | |
| f"{info.duration:.6f}", | |
| ] | |
| ) | |
| command.extend(_video_output_args(output_path)) | |
| command.append(str(output_path)) | |
| _run_checked(command) | |
| rendered = probe_video(output_path).duration | |
| tolerance = max(0.25, info.duration * 0.02) | |
| if abs(rendered - info.duration) > tolerance: | |
| raise VideoLoopError( | |
| "A marca d'agua alterou a duracao do video " | |
| f"({rendered:.2f}s em vez de {info.duration:.2f}s)." | |
| ) | |
| def apply_watermark( | |
| input_path: Path, | |
| output_path: Path, | |
| config: WatermarkConfig, | |
| ) -> None: | |
| """Create a watermarked MP4, supporting either text or an image.""" | |
| apply_watermarks(input_path, output_path, (config,)) | |
| def extract_frame( | |
| input_path: Path, | |
| output_png: Path, | |
| at_seconds: float = 0.5, | |
| max_side: int = 480, | |
| ) -> None: | |
| """Grab one clean frame, scaled to fit max_side, for the visual editor.""" | |
| ffmpeg = _find_executable("ffmpeg") | |
| output_png.parent.mkdir(parents=True, exist_ok=True) | |
| command = [ | |
| ffmpeg, | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-ss", | |
| f"{max(0.0, at_seconds):.3f}", | |
| "-i", | |
| str(input_path), | |
| "-vf", | |
| f"scale={max_side}:{max_side}:force_original_aspect_ratio=decrease", | |
| "-frames:v", | |
| "1", | |
| "-an", | |
| str(output_png), | |
| ] | |
| _run_checked(command) | |
| def render_watermark_preview( | |
| input_path: Path, | |
| output_png: Path, | |
| configs: tuple[WatermarkConfig, ...], | |
| at_seconds: float = 0.5, | |
| ) -> None: | |
| """Render an exact one-frame preview using the same FFmpeg filters as output.""" | |
| if not configs: | |
| extract_frame(input_path, output_png, at_seconds=at_seconds, max_side=560) | |
| return | |
| configs = tuple(_validate_watermark(config) for config in configs) | |
| ffmpeg = _find_executable("ffmpeg") | |
| output_png.parent.mkdir(parents=True, exist_ok=True) | |
| with tempfile.TemporaryDirectory(prefix="exact_watermark_preview_") as temporary_directory: | |
| filters, image_inputs, final_label = _watermark_chain( | |
| configs, Path(temporary_directory) | |
| ) | |
| command = [ | |
| ffmpeg, | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-ss", | |
| f"{max(0.0, at_seconds):.3f}", | |
| "-i", | |
| str(input_path), | |
| ] | |
| for image_path in image_inputs: | |
| command.extend(["-loop", "1", "-i", str(image_path)]) | |
| command.extend( | |
| [ | |
| "-filter_complex", | |
| f"{filters};{final_label}format=rgb24[out]", | |
| "-map", | |
| "[out]", | |
| "-frames:v", | |
| "1", | |
| "-an", | |
| str(output_png), | |
| ] | |
| ) | |
| _run_checked(command) | |
| def convert_to_gif( | |
| input_path: Path, | |
| output_path: Path, | |
| fps: int = 24, | |
| width: Optional[int] = None, | |
| ) -> None: | |
| """Convert a video to a looping GIF using a two-pass palette.""" | |
| if fps < 1: | |
| raise VideoLoopError("O FPS do GIF deve ser maior que zero.") | |
| if width is not None and width < 1: | |
| raise VideoLoopError("A largura do GIF deve ser maior que zero.") | |
| ffmpeg = _find_executable("ffmpeg") | |
| output_path.parent.mkdir(parents=True, exist_ok=True) | |
| video_filter = f"fps={fps}" | |
| if width is not None: | |
| video_filter += f",scale={width}:-1:flags=lanczos" | |
| with tempfile.TemporaryDirectory(prefix="gif_palette_") as temporary_directory: | |
| palette = Path(temporary_directory) / "palette.png" | |
| palette_command = [ | |
| ffmpeg, | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-i", | |
| str(input_path), | |
| "-vf", | |
| f"{video_filter},palettegen=stats_mode=diff", | |
| str(palette), | |
| ] | |
| _run_checked(palette_command) | |
| gif_command = [ | |
| ffmpeg, | |
| "-y", | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-i", | |
| str(input_path), | |
| "-i", | |
| str(palette), | |
| "-filter_complex", | |
| f"[0:v]{video_filter}[video];" | |
| f"[video][1:v]paletteuse=dither=sierra2_4a[out]", | |
| "-map", | |
| "[out]", | |
| "-an", | |
| "-loop", | |
| "0", | |
| "-map_metadata", | |
| "-1", | |
| "-map_chapters", | |
| "-1", | |
| str(output_path), | |
| ] | |
| _run_checked(gif_command) | |
| def _pipeline_gif_indices( | |
| options: PipelineOptions, | |
| total: int, | |
| selected_indices: Optional[set[int]], | |
| ) -> set[int]: | |
| if options.gif_mode == "none": | |
| return set() | |
| if options.gif_mode == "all": | |
| return set(range(total)) | |
| if options.gif_mode == "manual": | |
| return set(selected_indices or set()) | |
| if options.gif_mode == "random": | |
| count = max(0, min(options.gif_count, total)) | |
| return set(random.sample(range(total), count)) | |
| raise VideoLoopError("Modo de selecao de GIF desconhecido.") | |
| def process_pipeline( | |
| input_paths: list[Path], | |
| output_parent: Path, | |
| options: PipelineOptions, | |
| gif_selected_indices: Optional[set[int]] = None, | |
| watermark_indices: Optional[set[int]] = None, | |
| cancel_event: Optional[threading.Event] = None, | |
| status: StatusCallback = None, | |
| ) -> PipelineReport: | |
| """Run looping, variants, GIF conversion, renaming and metadata cleanup.""" | |
| paths = unique_video_paths(input_paths) | |
| if not paths: | |
| raise VideoLoopError("Adicione pelo menos um video para processar.") | |
| if not options.create_plain and not options.create_watermarked: | |
| raise VideoLoopError("Escolha pelo menos uma versao de video para criar.") | |
| if options.create_watermarked: | |
| watermark_configs = options.watermarks or ( | |
| (options.watermark,) if options.watermark is not None else () | |
| ) | |
| if not watermark_configs: | |
| raise VideoLoopError("Configure pelo menos uma marca d'agua.") | |
| watermark_configs = tuple(_validate_watermark(config) for config in watermark_configs) | |
| else: | |
| watermark_configs = () | |
| if options.gif_mode != "none" and options.gif_variant not in { | |
| "plain", | |
| "watermarked", | |
| "both", | |
| }: | |
| raise VideoLoopError("Versao de GIF desconhecida.") | |
| gif_indices = _pipeline_gif_indices(options, len(paths), gif_selected_indices) | |
| if options.gif_variant in {"watermarked", "both"} and options.gif_mode != "none" and not options.create_watermarked: | |
| raise VideoLoopError("GIF com marca exige a versao de video com marca.") | |
| prefix = sanitize_prefix(options.prefix) | |
| output_parent.mkdir(parents=True, exist_ok=True) | |
| loop_folder = output_parent / "looping" | |
| gif_folder = output_parent / "gif" | |
| loop_folder.mkdir(parents=True, exist_ok=True) | |
| gif_folder.mkdir(parents=True, exist_ok=True) | |
| watermarked_folder = loop_folder / "com_marca" | |
| if options.create_watermarked: | |
| watermarked_folder.mkdir(parents=True, exist_ok=True) | |
| outputs: list[Path] = [] | |
| gifs: list[Path] = [] | |
| failures: list[tuple[Path, str]] = [] | |
| skipped: list[Path] = [] | |
| created_videos: list[Path] = [] | |
| created_gifs: list[Path] = [] | |
| cancelled = False | |
| with tempfile.TemporaryDirectory(prefix="pipeline_work_") as temporary_directory: | |
| temporary_root = Path(temporary_directory) | |
| for index, input_path in enumerate(paths): | |
| if cancel_event is not None and cancel_event.is_set(): | |
| cancelled = True | |
| break | |
| filename = f"{prefix}_{index + 1:03d}.mp4" | |
| plain_path = loop_folder / filename if options.create_plain else None | |
| should_watermark = options.create_watermarked and ( | |
| watermark_indices is None or index in watermark_indices | |
| ) | |
| watermarked_path = ( | |
| watermarked_folder / filename if should_watermark else None | |
| ) | |
| gif_paths: list[tuple[str, Path]] = [] | |
| if index in gif_indices: | |
| if options.gif_variant in {"plain", "both"}: | |
| gif_paths.append(("plain", gif_folder / "sem_marca" / f"{Path(filename).stem}.gif")) | |
| if options.gif_variant in {"watermarked", "both"}: | |
| gif_paths.append(("watermarked", gif_folder / "com_marca" / f"{Path(filename).stem}.gif")) | |
| expected = [path for path in (plain_path, watermarked_path) if path is not None] | |
| expected.extend(path for _, path in gif_paths) | |
| if options.skip_existing and expected and all( | |
| _valid_generated_output(path) for path in expected | |
| ): | |
| skipped.append(input_path) | |
| outputs.extend(path for path in (plain_path, watermarked_path) if path is not None) | |
| gifs.extend(path for _, path in gif_paths) | |
| if status: | |
| status(f"[{index + 1}/{len(paths)}] Pulando existente: {input_path.name}") | |
| continue | |
| if status: | |
| status(f"[{index + 1}/{len(paths)}] Processando {input_path.name}...") | |
| try: | |
| working_path = temporary_root / f"working_{index + 1:03d}.mp4" | |
| if ( | |
| options.skip_existing | |
| and plain_path is not None | |
| and _valid_generated_output(plain_path) | |
| ): | |
| base_path = plain_path | |
| elif ( | |
| options.skip_existing | |
| and not options.create_plain | |
| and watermarked_path is not None | |
| and _valid_generated_output(watermarked_path) | |
| ): | |
| base_path = watermarked_path | |
| else: | |
| base_path = plain_path or working_path | |
| process_video( | |
| input_path, | |
| base_path, | |
| options.target_seconds, | |
| options.min_loop_seconds, | |
| status=status, | |
| ) | |
| if plain_path is not None: | |
| created_videos.append(plain_path) | |
| if options.clean_metadata: | |
| strip_metadata(plain_path) | |
| if ( | |
| should_watermark | |
| and watermarked_path is not None | |
| and (not _valid_generated_output(watermarked_path) or not options.skip_existing) | |
| ): | |
| apply_watermarks(base_path, watermarked_path, watermark_configs) | |
| created_videos.append(watermarked_path) | |
| if options.clean_metadata: | |
| strip_metadata(watermarked_path) | |
| if plain_path is not None: | |
| outputs.append(plain_path) | |
| if watermarked_path is not None: | |
| outputs.append(watermarked_path) | |
| original_watermarked_path: Optional[Path] = None | |
| if any(variant == "watermarked" for variant, _ in gif_paths): | |
| if status: | |
| status(f"[{index + 1}/{len(paths)}] Preparando marca do GIF original...") | |
| original_watermarked_path = ( | |
| temporary_root / f"gif_watermarked_{index + 1:03d}.mp4" | |
| ) | |
| apply_watermarks(input_path, original_watermarked_path, watermark_configs) | |
| if options.clean_metadata: | |
| strip_metadata(original_watermarked_path) | |
| for variant, gif_path in gif_paths: | |
| if options.skip_existing and _valid_generated_output(gif_path): | |
| gifs.append(gif_path) | |
| continue | |
| if variant == "plain": | |
| # GIFs intentionally use the original clip, not the extended loop. | |
| gif_source = input_path | |
| else: | |
| if original_watermarked_path is None: | |
| raise VideoLoopError( | |
| "Nao foi possivel preparar a marca d'agua do GIF original." | |
| ) | |
| gif_source = original_watermarked_path | |
| convert_to_gif( | |
| gif_source, | |
| gif_path, | |
| fps=options.gif_fps, | |
| width=options.gif_width, | |
| ) | |
| gifs.append(gif_path) | |
| created_gifs.append(gif_path) | |
| except Exception as exc: | |
| failures.append((input_path, str(exc))) | |
| if status: | |
| status(f"[{index + 1}/{len(paths)}] Falha: {input_path.name}: {exc}") | |
| return PipelineReport( | |
| output_folder=output_parent, | |
| loop_folder=loop_folder, | |
| gif_folder=gif_folder, | |
| outputs=tuple(outputs), | |
| gifs=tuple(gifs), | |
| failures=tuple(failures), | |
| skipped=tuple(skipped), | |
| cancelled=cancelled, | |
| created_videos=tuple(created_videos), | |
| created_gifs=tuple(created_gifs), | |
| ) | |
| def _format_seconds(seconds: float) -> str: | |
| minutes, remaining = divmod(max(0.0, seconds), 60.0) | |
| if minutes >= 60: | |
| hours, minutes = divmod(int(minutes), 60) | |
| return f"{hours}h {int(minutes):02d}m {remaining:04.1f}s" | |
| if minutes >= 1: | |
| return f"{int(minutes)}m {remaining:04.1f}s" | |
| return f"{remaining:.2f}s" | |
| TkBase = TkinterDnD.Tk if TkinterDnD is not None else tk.Tk | |
| class LegacyLoopApp(TkBase): | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.title("Detector de Loop de Video") | |
| self.geometry("1080x720") | |
| self.minsize(920, 640) | |
| self.protocol("WM_DELETE_WINDOW", self._on_close) | |
| self.input_paths: list[Path] = [] | |
| self.output_dir_var = tk.StringVar() | |
| self.target_var = tk.StringVar(value=str(int(DEFAULT_TARGET_SECONDS))) | |
| self.minimum_var = tk.StringVar(value=str(int(DEFAULT_MIN_LOOP_SECONDS))) | |
| self.skip_existing_var = tk.BooleanVar(value=True) | |
| self.prefix_var = tk.StringVar(value="looping") | |
| self.create_plain_var = tk.BooleanVar(value=True) | |
| self.create_watermarked_var = tk.BooleanVar(value=False) | |
| self.clean_metadata_var = tk.BooleanVar(value=True) | |
| self.watermark_mode_var = tk.StringVar(value="text") | |
| self.watermark_text_var = tk.StringVar(value="Python/Tabu") | |
| self.watermark_image_var = tk.StringVar() | |
| self.watermark_opacity_var = tk.StringVar(value="0.80") | |
| self.watermark_size_var = tk.StringVar(value="36") | |
| self.watermark_scale_var = tk.StringVar(value="25") | |
| self.watermark_position_var = tk.StringVar(value="inferior direito") | |
| self.watermark_scope_var = tk.StringVar(value="todos") | |
| self.watermark_x_var = tk.StringVar() | |
| self.watermark_y_var = tk.StringVar() | |
| self.preset_var = tk.StringVar() | |
| self.gif_mode_var = tk.StringVar(value="nao criar GIF") | |
| self.gif_random_count_var = tk.StringVar(value="1") | |
| self.gif_variant_var = tk.StringVar(value="sem marca") | |
| self.gif_width_var = tk.StringVar() | |
| self.status_var = tk.StringVar(value="Adicione um ou mais videos para comecar.") | |
| self.details_var = tk.StringVar() | |
| self.preview_status_var = tk.StringVar(value="O preview aparecera depois do processamento.") | |
| self.cancel_event = threading.Event() | |
| self.processing = False | |
| self.closing = False | |
| self.last_output_folder: Optional[Path] = None | |
| self.preview_tempdir: Optional[tempfile.TemporaryDirectory] = None | |
| self.preview_frames: list[Path] = [] | |
| self.preview_index = 0 | |
| self.preview_playing = False | |
| self.preview_after_id: Optional[str] = None | |
| self.preview_photo = None | |
| self.editor_mode = False | |
| self.editor_bg_photo = None | |
| self.editor_overlay_photo = None | |
| self.editor_canvas = None | |
| self.editor_items: list[int] = [] | |
| self.editor_frame_size = (0, 0) | |
| self.editor_source_size = (0, 0) | |
| self.presets_file = Path(__file__).with_name("watermark_presets.json") | |
| self._build_widgets() | |
| self._enable_drag_and_drop() | |
| self._refresh_preset_combo() | |
| def _build_widgets(self) -> None: | |
| root = ttk.Frame(self, padding=18) | |
| root.pack(fill="both", expand=True) | |
| root.columnconfigure(0, weight=3) | |
| root.columnconfigure(1, weight=2) | |
| root.rowconfigure(2, weight=1) | |
| ttk.Label( | |
| root, | |
| text="Detector e Amplificador de Loop", | |
| font=("Segoe UI", 17, "bold"), | |
| ).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 3)) | |
| ttk.Label( | |
| root, | |
| text="Selecione varios videos, processe em lote e confira o resultado no preview.", | |
| ).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 0)) | |
| left = ttk.LabelFrame(root, text="Videos para processar", padding=12) | |
| left.grid(row=2, column=0, sticky="nsew", padx=(0, 10), pady=(18, 0)) | |
| left.columnconfigure(0, weight=1) | |
| left.rowconfigure(1, weight=1) | |
| drop_text = ( | |
| "Arraste arquivos ou pastas para esta lista" | |
| if DND_FILES is not None | |
| else "Use os botoes abaixo para adicionar arquivos ou pastas" | |
| ) | |
| self.drop_hint = ttk.Label(left, text=drop_text) | |
| self.drop_hint.grid(row=0, column=0, sticky="w", pady=(0, 7)) | |
| list_frame = ttk.Frame(left) | |
| list_frame.grid(row=1, column=0, sticky="nsew") | |
| list_frame.columnconfigure(0, weight=1) | |
| list_frame.rowconfigure(0, weight=1) | |
| self.file_list = tk.Listbox( | |
| list_frame, | |
| selectmode=tk.EXTENDED, | |
| exportselection=False, | |
| activestyle="none", | |
| height=12, | |
| ) | |
| self.file_list.grid(row=0, column=0, sticky="nsew") | |
| file_scroll = ttk.Scrollbar(list_frame, orient="vertical", command=self.file_list.yview) | |
| file_scroll.grid(row=0, column=1, sticky="ns") | |
| self.file_list.configure(yscrollcommand=file_scroll.set) | |
| file_buttons = ttk.Frame(left) | |
| file_buttons.grid(row=2, column=0, sticky="ew", pady=(9, 0)) | |
| for column in range(4): | |
| file_buttons.columnconfigure(column, weight=1) | |
| self.add_files_button = ttk.Button( | |
| file_buttons, text="Adicionar arquivos", command=self._choose_files | |
| ) | |
| self.add_files_button.grid(row=0, column=0, sticky="ew", padx=(0, 4)) | |
| self.add_folder_button = ttk.Button( | |
| file_buttons, text="Adicionar pasta", command=self._choose_input_folder | |
| ) | |
| self.add_folder_button.grid(row=0, column=1, sticky="ew", padx=4) | |
| self.remove_button = ttk.Button( | |
| file_buttons, text="Remover", command=self._remove_selected | |
| ) | |
| self.remove_button.grid(row=0, column=2, sticky="ew", padx=4) | |
| self.clear_button = ttk.Button( | |
| file_buttons, text="Limpar", command=self._clear_files | |
| ) | |
| self.clear_button.grid(row=0, column=3, sticky="ew", padx=(4, 0)) | |
| self.file_buttons = [ | |
| self.add_files_button, | |
| self.add_folder_button, | |
| self.remove_button, | |
| self.clear_button, | |
| ] | |
| output_frame = ttk.LabelFrame(left, text="Destino", padding=10) | |
| output_frame.grid(row=3, column=0, sticky="ew", pady=(14, 0)) | |
| output_frame.columnconfigure(1, weight=1) | |
| ttk.Label(output_frame, text="Pasta escolhida:").grid( | |
| row=0, column=0, sticky="w", padx=(0, 8) | |
| ) | |
| self.output_entry = ttk.Entry(output_frame, textvariable=self.output_dir_var) | |
| self.output_entry.grid(row=0, column=1, sticky="ew") | |
| self.output_button = ttk.Button( | |
| output_frame, text="Escolher...", command=self._choose_output_folder | |
| ) | |
| self.output_button.grid(row=0, column=2, padx=(8, 0)) | |
| ttk.Label( | |
| output_frame, | |
| text="Os arquivos serao criados automaticamente em: pasta escolhida\\looping", | |
| ).grid(row=1, column=0, columnspan=3, sticky="w", pady=(7, 0)) | |
| options = ttk.Notebook(left) | |
| options.grid(row=4, column=0, sticky="ew", pady=(12, 0)) | |
| process_tab = ttk.Frame(options, padding=9) | |
| process_tab.columnconfigure(1, weight=1) | |
| options.add(process_tab, text="Processamento") | |
| ttk.Label(process_tab, text="Duracao desejada (s):").grid( | |
| row=0, column=0, sticky="w", padx=(0, 8), pady=2 | |
| ) | |
| ttk.Entry(process_tab, textvariable=self.target_var, width=10).grid( | |
| row=0, column=1, sticky="w", pady=2 | |
| ) | |
| ttk.Label(process_tab, text="Loop minimo (s):").grid( | |
| row=1, column=0, sticky="w", padx=(0, 8), pady=2 | |
| ) | |
| ttk.Entry(process_tab, textvariable=self.minimum_var, width=10).grid( | |
| row=1, column=1, sticky="w", pady=2 | |
| ) | |
| ttk.Label(process_tab, text="Prefixo dos arquivos:").grid( | |
| row=2, column=0, sticky="w", padx=(0, 8), pady=2 | |
| ) | |
| ttk.Entry(process_tab, textvariable=self.prefix_var, width=18).grid( | |
| row=2, column=1, sticky="w", pady=2 | |
| ) | |
| self.plain_check = ttk.Checkbutton( | |
| process_tab, | |
| text="Criar versao sem marca d'agua", | |
| variable=self.create_plain_var, | |
| ) | |
| self.plain_check.grid(row=3, column=0, columnspan=2, sticky="w", pady=(4, 0)) | |
| self.watermarked_check = ttk.Checkbutton( | |
| process_tab, | |
| text="Criar versao com marca d'agua", | |
| variable=self.create_watermarked_var, | |
| ) | |
| self.watermarked_check.grid(row=4, column=0, columnspan=2, sticky="w") | |
| self.metadata_check = ttk.Checkbutton( | |
| process_tab, | |
| text="Limpar metadados das saidas", | |
| variable=self.clean_metadata_var, | |
| ) | |
| self.metadata_check.grid(row=5, column=0, columnspan=2, sticky="w") | |
| self.skip_check = ttk.Checkbutton( | |
| process_tab, | |
| text="Pular resultados que ja existem", | |
| variable=self.skip_existing_var, | |
| ) | |
| self.skip_check.grid(row=6, column=0, columnspan=2, sticky="w") | |
| watermark_tab = ttk.Frame(options, padding=9) | |
| watermark_tab.columnconfigure(1, weight=1) | |
| options.add(watermark_tab, text="Marca d'agua") | |
| ttk.Label(watermark_tab, text="Marca salva:").grid( | |
| row=0, column=0, sticky="w", pady=2 | |
| ) | |
| self.preset_combo = ttk.Combobox( | |
| watermark_tab, | |
| textvariable=self.preset_var, | |
| state="readonly", | |
| width=22, | |
| ) | |
| self.preset_combo.grid(row=0, column=1, sticky="w", pady=2) | |
| self.preset_combo.bind("<<ComboboxSelected>>", self._load_selected_preset) | |
| self.preset_save_button = ttk.Button( | |
| watermark_tab, text="Salvar", command=self._save_preset | |
| ) | |
| self.preset_save_button.grid(row=0, column=2, padx=(6, 0), pady=2) | |
| self.preset_delete_button = ttk.Button( | |
| watermark_tab, text="Excluir", command=self._delete_selected_preset | |
| ) | |
| self.preset_delete_button.grid(row=0, column=3, padx=(4, 0), pady=2) | |
| ttk.Label(watermark_tab, text="Tipo:").grid(row=1, column=0, sticky="w") | |
| ttk.Radiobutton( | |
| watermark_tab, | |
| text="Texto", | |
| value="text", | |
| variable=self.watermark_mode_var, | |
| command=self._toggle_watermark_fields, | |
| ).grid(row=1, column=1, sticky="w") | |
| ttk.Radiobutton( | |
| watermark_tab, | |
| text="Imagem", | |
| value="image", | |
| variable=self.watermark_mode_var, | |
| command=self._toggle_watermark_fields, | |
| ).grid(row=1, column=2, sticky="w") | |
| ttk.Label(watermark_tab, text="Texto:").grid(row=2, column=0, sticky="w", pady=2) | |
| self.watermark_text_entry = ttk.Entry( | |
| watermark_tab, textvariable=self.watermark_text_var, width=24 | |
| ) | |
| self.watermark_text_entry.grid(row=2, column=1, columnspan=2, sticky="ew", pady=2) | |
| ttk.Label(watermark_tab, text="Imagem:").grid(row=3, column=0, sticky="w", pady=2) | |
| self.watermark_image_entry = ttk.Entry( | |
| watermark_tab, textvariable=self.watermark_image_var, width=24 | |
| ) | |
| self.watermark_image_entry.grid(row=3, column=1, sticky="ew", pady=2) | |
| self.watermark_image_button = ttk.Button( | |
| watermark_tab, text="Escolher", command=self._choose_watermark_image | |
| ) | |
| self.watermark_image_button.grid(row=3, column=2, padx=(6, 0), pady=2) | |
| ttk.Label(watermark_tab, text="Opacidade:").grid(row=4, column=0, sticky="w", pady=2) | |
| self.watermark_opacity_entry = ttk.Entry( | |
| watermark_tab, textvariable=self.watermark_opacity_var, width=8 | |
| ) | |
| self.watermark_opacity_entry.grid(row=4, column=1, sticky="w", pady=2) | |
| ttk.Label(watermark_tab, text="Tamanho texto:").grid(row=5, column=0, sticky="w", pady=2) | |
| self.watermark_size_entry = ttk.Entry( | |
| watermark_tab, textvariable=self.watermark_size_var, width=8 | |
| ) | |
| self.watermark_size_entry.grid(row=5, column=1, sticky="w", pady=2) | |
| ttk.Label(watermark_tab, text="Escala imagem (%):").grid(row=6, column=0, sticky="w", pady=2) | |
| self.watermark_scale_entry = ttk.Entry( | |
| watermark_tab, textvariable=self.watermark_scale_var, width=8 | |
| ) | |
| self.watermark_scale_entry.grid(row=6, column=1, sticky="w", pady=2) | |
| ttk.Label(watermark_tab, text="X (%):").grid(row=7, column=0, sticky="w", pady=2) | |
| self.watermark_x_entry = ttk.Entry( | |
| watermark_tab, textvariable=self.watermark_x_var, width=8 | |
| ) | |
| self.watermark_x_entry.grid(row=7, column=1, sticky="w", pady=2) | |
| ttk.Label(watermark_tab, text="Y (%):").grid(row=8, column=0, sticky="w", pady=2) | |
| self.watermark_y_entry = ttk.Entry( | |
| watermark_tab, textvariable=self.watermark_y_var, width=8 | |
| ) | |
| self.watermark_y_entry.grid(row=8, column=1, sticky="w", pady=2) | |
| ttk.Label(watermark_tab, text="0 = borda esquerda/topo, 100 = direita/base").grid( | |
| row=9, column=0, columnspan=3, sticky="w", pady=(3, 0) | |
| ) | |
| ttk.Label(watermark_tab, text="Posicao padrao:").grid(row=10, column=0, sticky="w", pady=2) | |
| self.watermark_position_combo = ttk.Combobox( | |
| watermark_tab, | |
| textvariable=self.watermark_position_var, | |
| values=tuple(POSITION_LABELS), | |
| state="readonly", | |
| width=16, | |
| ) | |
| self.watermark_position_combo.grid(row=10, column=1, sticky="w", pady=2) | |
| ttk.Label(watermark_tab, text="Aplicar em:").grid(row=11, column=0, sticky="w", pady=2) | |
| self.watermark_scope_combo = ttk.Combobox( | |
| watermark_tab, | |
| textvariable=self.watermark_scope_var, | |
| values=tuple(SCOPE_LABELS), | |
| state="readonly", | |
| width=16, | |
| ) | |
| self.watermark_scope_combo.grid(row=11, column=1, sticky="w", pady=2) | |
| ttk.Label( | |
| watermark_tab, text="todos = todos; selecionados = itens marcados na lista" | |
| ).grid(row=12, column=0, columnspan=3, sticky="w", pady=(3, 0)) | |
| self.watermark_preview_button = ttk.Button( | |
| watermark_tab, | |
| text="Editor visual da marca d'agua", | |
| command=self._open_watermark_editor, | |
| ) | |
| self.watermark_preview_button.grid( | |
| row=13, column=0, columnspan=3, sticky="ew", pady=(6, 0) | |
| ) | |
| for entry in ( | |
| self.watermark_text_entry, | |
| self.watermark_image_entry, | |
| self.watermark_opacity_entry, | |
| self.watermark_size_entry, | |
| self.watermark_scale_entry, | |
| self.watermark_x_entry, | |
| self.watermark_y_entry, | |
| ): | |
| entry.bind("<KeyRelease>", self._update_editor_overlay) | |
| self.watermark_position_combo.bind( | |
| "<<ComboboxSelected>>", self._update_editor_overlay | |
| ) | |
| gif_tab = ttk.Frame(options, padding=9) | |
| gif_tab.columnconfigure(1, weight=1) | |
| options.add(gif_tab, text="GIF") | |
| ttk.Label(gif_tab, text="Converter para GIF:").grid(row=0, column=0, sticky="w", pady=2) | |
| self.gif_mode_combo = ttk.Combobox( | |
| gif_tab, | |
| textvariable=self.gif_mode_var, | |
| values=tuple(GIF_MODE_LABELS), | |
| state="readonly", | |
| width=16, | |
| ) | |
| self.gif_mode_combo.grid(row=0, column=1, sticky="w", pady=2) | |
| self.gif_mode_combo.bind("<<ComboboxSelected>>", self._toggle_gif_fields) | |
| ttk.Label(gif_tab, text="aleatorios: quantidade").grid(row=1, column=0, sticky="w", pady=2) | |
| self.gif_random_entry = ttk.Entry(gif_tab, textvariable=self.gif_random_count_var, width=8) | |
| self.gif_random_entry.grid(row=1, column=1, sticky="w", pady=2) | |
| ttk.Label(gif_tab, text="Fonte do GIF:").grid(row=2, column=0, sticky="w", pady=2) | |
| self.gif_variant_combo = ttk.Combobox( | |
| gif_tab, | |
| textvariable=self.gif_variant_var, | |
| values=tuple(GIF_VARIANT_LABELS), | |
| state="readonly", | |
| width=16, | |
| ) | |
| self.gif_variant_combo.grid(row=2, column=1, sticky="w", pady=2) | |
| ttk.Label(gif_tab, text="FPS:").grid(row=3, column=0, sticky="w", pady=2) | |
| ttk.Label(gif_tab, text="24 (fixo)").grid(row=3, column=1, sticky="w", pady=2) | |
| ttk.Label(gif_tab, text="Largura:").grid(row=4, column=0, sticky="w", pady=2) | |
| self.gif_width_entry = ttk.Entry(gif_tab, textvariable=self.gif_width_var, width=10) | |
| self.gif_width_entry.grid(row=4, column=1, sticky="w", pady=2) | |
| ttk.Label(gif_tab, text="vazio = mesma resolucao do video").grid( | |
| row=5, column=0, columnspan=2, sticky="w", pady=(3, 0) | |
| ) | |
| self._toggle_watermark_fields() | |
| self._toggle_gif_fields() | |
| actions = ttk.Frame(left) | |
| actions.grid(row=5, column=0, sticky="ew", pady=(14, 0)) | |
| actions.columnconfigure(0, weight=1) | |
| actions.columnconfigure(1, weight=1) | |
| self.process_button = ttk.Button( | |
| actions, text="Encontrar e criar loopings", command=self._start | |
| ) | |
| self.process_button.grid(row=0, column=0, sticky="ew", padx=(0, 5)) | |
| self.cancel_button = ttk.Button( | |
| actions, text="Parar depois do atual", command=self._cancel, state="disabled" | |
| ) | |
| self.cancel_button.grid(row=0, column=1, sticky="ew", padx=(5, 0)) | |
| self.progress = ttk.Progressbar(actions, mode="indeterminate") | |
| self.progress.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0)) | |
| preview = ttk.LabelFrame(root, text="Preview do resultado", padding=12) | |
| preview.grid(row=2, column=1, sticky="nsew", pady=(18, 0)) | |
| preview.columnconfigure(0, weight=1) | |
| preview.rowconfigure(0, weight=1) | |
| self.preview_frame_label = ttk.Label( | |
| preview, | |
| text="Nenhum resultado carregado.", | |
| anchor="center", | |
| justify="center", | |
| ) | |
| self.preview_frame_label.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) | |
| self.preview_editor_canvas = tk.Canvas( | |
| preview, highlightthickness=0, background="#1e1e1e" | |
| ) | |
| self.preview_editor_canvas.bind("<Button-1>", self._editor_press) | |
| self.preview_editor_canvas.bind("<B1-Motion>", self._editor_drag) | |
| self.editor_canvas = self.preview_editor_canvas | |
| ttk.Label(preview, textvariable=self.preview_status_var).grid( | |
| row=1, column=0, sticky="w", pady=(0, 8) | |
| ) | |
| preview_buttons = ttk.Frame(preview) | |
| preview_buttons.grid(row=2, column=0, sticky="ew") | |
| preview_buttons.columnconfigure(1, weight=1) | |
| self.previous_button = ttk.Button( | |
| preview_buttons, text="Anterior", command=self._previous_preview, state="disabled" | |
| ) | |
| self.previous_button.grid(row=0, column=0, sticky="ew", padx=(0, 3)) | |
| self.play_button = ttk.Button( | |
| preview_buttons, text="Reproduzir", command=self._toggle_preview, state="disabled" | |
| ) | |
| self.play_button.grid(row=0, column=1, sticky="ew", padx=3) | |
| self.next_button = ttk.Button( | |
| preview_buttons, text="Proximo", command=self._next_preview, state="disabled" | |
| ) | |
| self.next_button.grid(row=0, column=2, sticky="ew", padx=(3, 0)) | |
| self.open_folder_button = ttk.Button( | |
| preview, | |
| text="Abrir pasta looping", | |
| command=self._open_output_folder, | |
| state="disabled", | |
| ) | |
| self.open_folder_button.grid(row=3, column=0, sticky="ew", pady=(10, 0)) | |
| ttk.Separator(root).grid(row=3, column=0, columnspan=2, sticky="ew", pady=(14, 8)) | |
| ttk.Label(root, textvariable=self.status_var, wraplength=1020).grid( | |
| row=4, column=0, columnspan=2, sticky="w" | |
| ) | |
| ttk.Label(root, textvariable=self.details_var, wraplength=1020).grid( | |
| row=5, column=0, columnspan=2, sticky="w", pady=(4, 0) | |
| ) | |
| log_frame = ttk.LabelFrame(root, text="Log do processamento", padding=5) | |
| log_frame.grid(row=6, column=0, columnspan=2, sticky="ew", pady=(6, 0)) | |
| log_frame.columnconfigure(0, weight=1) | |
| self.log_text = tk.Text(log_frame, height=4, state="disabled", wrap="word") | |
| self.log_text.grid(row=0, column=0, sticky="ew") | |
| log_scroll = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview) | |
| log_scroll.grid(row=0, column=1, sticky="ns") | |
| self.log_text.configure(yscrollcommand=log_scroll.set) | |
| def _toggle_watermark_fields(self) -> None: | |
| text_state = "normal" if self.watermark_mode_var.get() == "text" else "disabled" | |
| image_state = "normal" if self.watermark_mode_var.get() == "image" else "disabled" | |
| self.watermark_text_entry.configure(state=text_state) | |
| self.watermark_image_entry.configure(state=image_state) | |
| self.watermark_image_button.configure(state=image_state) | |
| self._update_editor_overlay() | |
| def _toggle_gif_fields(self, _event: object = None) -> None: | |
| mode = GIF_MODE_LABELS.get(self.gif_mode_var.get(), "none") | |
| random_state = "normal" if mode == "random" else "disabled" | |
| variant_state = "normal" if mode != "none" else "disabled" | |
| self.gif_random_entry.configure(state=random_state) | |
| self.gif_variant_combo.configure( | |
| state="readonly" if variant_state == "normal" else "disabled" | |
| ) | |
| self.gif_width_entry.configure(state=variant_state) | |
| def _choose_watermark_image(self) -> None: | |
| path = filedialog.askopenfilename( | |
| title="Escolha a imagem da marca d'agua", | |
| filetypes=[ | |
| ("Imagens", "*.png *.jpg *.jpeg *.webp *.bmp"), | |
| ("Todos os arquivos", "*.*"), | |
| ], | |
| ) | |
| if path: | |
| self.watermark_image_var.set(path) | |
| def _build_watermark_config(self) -> WatermarkConfig: | |
| try: | |
| opacity = float(self.watermark_opacity_var.get().replace(",", ".")) | |
| font_size = int(self.watermark_size_var.get()) | |
| image_scale = int(self.watermark_scale_var.get()) | |
| except ValueError as exc: | |
| raise VideoLoopError( | |
| "Opacidade, tamanho do texto e escala precisam ser numericos." | |
| ) from exc | |
| x_text = self.watermark_x_var.get().strip() | |
| y_text = self.watermark_y_var.get().strip() | |
| try: | |
| x_percent = float(x_text.replace(",", ".")) if x_text else None | |
| y_percent = float(y_text.replace(",", ".")) if y_text else None | |
| except ValueError as exc: | |
| raise VideoLoopError( | |
| "As posicoes X e Y da marca d'agua devem ser numeros de 0 a 100." | |
| ) from exc | |
| image_text = self.watermark_image_var.get().strip() | |
| return WatermarkConfig( | |
| mode=self.watermark_mode_var.get(), | |
| text=self.watermark_text_var.get(), | |
| image_path=Path(image_text) if image_text else None, | |
| opacity=opacity, | |
| font_size=font_size, | |
| image_scale=image_scale, | |
| position=POSITION_LABELS.get(self.watermark_position_var.get(), "bottom-right"), | |
| x_percent=x_percent, | |
| y_percent=y_percent, | |
| ) | |
| def _open_watermark_editor(self) -> None: | |
| if not self.input_paths: | |
| messagebox.showwarning("Editor visual", "Adicione um video primeiro.") | |
| return | |
| selected = self.file_list.curselection() | |
| input_path = self.input_paths[selected[0]] if selected else self.input_paths[0] | |
| try: | |
| source_info = probe_video(input_path) | |
| except VideoLoopError as exc: | |
| messagebox.showerror("Editor visual", str(exc)) | |
| return | |
| self._stop_preview_playback() | |
| if self.preview_tempdir is not None: | |
| self.preview_tempdir.cleanup() | |
| self.preview_tempdir = tempfile.TemporaryDirectory(prefix="watermark_editor_") | |
| frame_path = Path(self.preview_tempdir.name) / "frame.png" | |
| self._set_preview_mode("editor") | |
| self.editor_canvas.delete("all") | |
| self.editor_items = [] | |
| self.editor_clean_image = None | |
| self.editor_exact_image = None | |
| self.editor_bg_image = None | |
| self.editor_bg_photo = None | |
| self.editor_overlay_photo = None | |
| self.preview_status_var.set("Carregando quadro do video...") | |
| threading.Thread( | |
| target=self._editor_worker, | |
| args=(input_path, frame_path, source_info), | |
| daemon=True, | |
| ).start() | |
| def _editor_worker( | |
| self, | |
| input_path: Path, | |
| frame_path: Path, | |
| source_info: VideoInfo, | |
| ) -> None: | |
| try: | |
| extract_frame(input_path, frame_path, at_seconds=0.5, max_side=480) | |
| except Exception as exc: | |
| self._safe_after(self._preview_failed, str(exc)) | |
| return | |
| self._safe_after(self._editor_frame_ready, frame_path, source_info) | |
| def _editor_frame_ready(self, frame_path: Path, source_info: VideoInfo) -> None: | |
| if Image is None or ImageTk is None: | |
| self.preview_status_var.set( | |
| "Editor visual precisa do Pillow: python -m pip install Pillow" | |
| ) | |
| return | |
| try: | |
| with Image.open(frame_path) as image: | |
| background = ImageTk.PhotoImage(image.convert("RGB")) | |
| except (OSError, tk.TclError) as exc: | |
| self.preview_status_var.set(f"Editor visual: {exc}") | |
| return | |
| canvas = self.preview_editor_canvas | |
| self.editor_bg_photo = background | |
| canvas.configure(width=background.width(), height=background.height()) | |
| canvas.delete("all") | |
| self.editor_items = [canvas.create_image(0, 0, image=background, anchor="nw")] | |
| self.editor_frame_size = (background.width(), background.height()) | |
| self.editor_source_size = (source_info.width or 1, source_info.height or 1) | |
| if not self.watermark_x_var.get().strip(): | |
| self.watermark_x_var.set("50.0") | |
| if not self.watermark_y_var.get().strip(): | |
| self.watermark_y_var.set("80.0") | |
| self._update_editor_overlay() | |
| self.preview_status_var.set( | |
| "Arraste a marca d'agua ate o lugar certo; o X/Y sao atualizados ao vivo." | |
| ) | |
| def _set_preview_mode(self, mode: str) -> None: | |
| if mode == "editor": | |
| self.preview_frame_label.grid_remove() | |
| self.preview_editor_canvas.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) | |
| self.editor_mode = True | |
| else: | |
| self.preview_editor_canvas.grid_remove() | |
| self.preview_frame_label.grid(row=0, column=0, sticky="nsew", pady=(0, 10)) | |
| self.editor_mode = False | |
| def _editor_opacity(self) -> int: | |
| try: | |
| opacity = float(self.watermark_opacity_var.get().replace(",", ".")) | |
| except ValueError: | |
| opacity = 0.8 | |
| return int(min(1.0, max(0.05, opacity)) * 255) | |
| def _editor_image_scale(self) -> int: | |
| try: | |
| return min(100, max(1, int(self.watermark_scale_var.get()))) | |
| except ValueError: | |
| return 25 | |
| def _update_editor_overlay(self, _event: object = None) -> None: | |
| if ( | |
| not self.editor_mode | |
| or self.editor_canvas is None | |
| or self.editor_bg_photo is None | |
| or Image is None | |
| or ImageTk is None | |
| ): | |
| return | |
| frame_w, frame_h = self.editor_frame_size | |
| if frame_w <= 0 or frame_h <= 0: | |
| return | |
| try: | |
| fx = min(1.0, max(0.0, float(self.watermark_x_var.get().replace(",", ".")) / 100.0)) | |
| fy = min(1.0, max(0.0, float(self.watermark_y_var.get().replace(",", ".")) / 100.0)) | |
| except ValueError: | |
| return | |
| overlay = Image.new("RGBA", (frame_w, frame_h), (0, 0, 0, 0)) | |
| alpha = self._editor_opacity() | |
| if self.watermark_mode_var.get() == "text": | |
| self._draw_editor_text(overlay, fx, fy, alpha) | |
| else: | |
| self._draw_editor_image(overlay, fx, fy, alpha) | |
| self.editor_overlay_photo = ImageTk.PhotoImage(overlay) | |
| canvas = self.editor_canvas | |
| for item in self.editor_items[1:]: | |
| canvas.delete(item) | |
| self.editor_items = self.editor_items[:1] | |
| self.editor_items.append( | |
| canvas.create_image(0, 0, image=self.editor_overlay_photo, anchor="nw") | |
| ) | |
| def _draw_editor_text( | |
| self, | |
| overlay: "Image.Image", | |
| fx: float, | |
| fy: float, | |
| alpha: int, | |
| ) -> None: | |
| draw = ImageDraw.Draw(overlay) | |
| frame_w, frame_h = self.editor_frame_size | |
| source_w = self.editor_source_size[0] or frame_w | |
| factor = frame_w / max(1, source_w) | |
| try: | |
| size = max(6, int(float(self.watermark_size_var.get()) * factor)) | |
| except ValueError: | |
| size = max(6, int(36 * factor)) | |
| font = None | |
| font_path = _default_font_file() | |
| if ImageFont is not None and font_path: | |
| try: | |
| font = ImageFont.truetype(str(font_path), size) | |
| except OSError: | |
| font = None | |
| if font is None: | |
| font = ImageFont.load_default() | |
| draw.multiline_text( | |
| (fx * frame_w, fy * frame_h), | |
| self.watermark_text_var.get() or " ", | |
| font=font, | |
| fill=(255, 255, 255, alpha), | |
| anchor="mm", | |
| align="center", | |
| spacing=4, | |
| stroke_width=2, | |
| stroke_fill=(0, 0, 0, int(alpha * 0.55)), | |
| ) | |
| def _draw_editor_image( | |
| self, | |
| overlay: "Image.Image", | |
| fx: float, | |
| fy: float, | |
| alpha: int, | |
| ) -> None: | |
| path_text = self.watermark_image_var.get().strip() | |
| if not path_text: | |
| return | |
| try: | |
| stamp = Image.open(path_text).convert("RGBA") | |
| except OSError: | |
| return | |
| frame_w, frame_h = self.editor_frame_size | |
| target_w = max(8, int(frame_w * self._editor_image_scale() / 100.0)) | |
| target_h = max(1, int(stamp.height * target_w / max(1, stamp.width))) | |
| stamp = stamp.resize((target_w, target_h)) | |
| stamp.putalpha(stamp.split()[3].point(lambda value: value * alpha // 255)) | |
| x = int(min(max(0, frame_w - stamp.width), fx * frame_w - stamp.width / 2)) | |
| y = int(min(max(0, frame_h - stamp.height), fy * frame_h - stamp.height / 2)) | |
| overlay.alpha_composite(stamp, (x, y)) | |
| def _editor_press(self, event: object) -> None: | |
| self._editor_drag(event) | |
| def _editor_drag(self, event: object) -> None: | |
| if not self.editor_mode or self.editor_frame_size[0] <= 0: | |
| return | |
| frame_w, frame_h = self.editor_frame_size | |
| fx = min(1.0, max(0.0, event.x / frame_w)) | |
| fy = min(1.0, max(0.0, event.y / frame_h)) | |
| self.watermark_x_var.set(f"{fx * 100:.1f}") | |
| self.watermark_y_var.set(f"{fy * 100:.1f}") | |
| self._update_editor_overlay() | |
| def _read_presets(self) -> dict: | |
| if not self.presets_file.is_file(): | |
| return {} | |
| try: | |
| data = json.loads(self.presets_file.read_text(encoding="utf-8")) | |
| except (json.JSONDecodeError, OSError): | |
| return {} | |
| return data if isinstance(data, dict) else {} | |
| def _write_presets(self, presets: dict) -> None: | |
| self.presets_file.write_text( | |
| json.dumps(presets, ensure_ascii=False, indent=2), | |
| encoding="utf-8", | |
| ) | |
| def _refresh_preset_combo(self) -> None: | |
| presets = self._read_presets() | |
| self.preset_combo.configure(values=tuple(sorted(presets))) | |
| if self.preset_var.get() not in presets: | |
| self.preset_var.set("") | |
| def _save_preset(self) -> None: | |
| name = (simpledialog.askstring( | |
| "Salvar marca d'agua", "Nome da marca d'agua:", parent=self | |
| ) or "").strip() | |
| if not name: | |
| return | |
| presets = self._read_presets() | |
| presets[name] = { | |
| "mode": self.watermark_mode_var.get(), | |
| "text": self.watermark_text_var.get(), | |
| "image_path": self.watermark_image_var.get(), | |
| "opacity": self.watermark_opacity_var.get(), | |
| "font_size": self.watermark_size_var.get(), | |
| "image_scale": self.watermark_scale_var.get(), | |
| "position": self.watermark_position_var.get(), | |
| "x_percent": self.watermark_x_var.get(), | |
| "y_percent": self.watermark_y_var.get(), | |
| } | |
| try: | |
| self._write_presets(presets) | |
| except OSError as exc: | |
| messagebox.showerror("Marca d'agua", f"Nao foi possivel salvar: {exc}") | |
| return | |
| self._refresh_preset_combo() | |
| self.preset_var.set(name) | |
| self.status_var.set(f"Marca d'agua '{name}' salva.") | |
| def _load_selected_preset(self, _event: object = None) -> None: | |
| name = self.preset_var.get() | |
| preset = self._read_presets().get(name) | |
| if not preset: | |
| return | |
| self.watermark_mode_var.set(preset.get("mode", "text")) | |
| self.watermark_text_var.set(preset.get("text", "")) | |
| self.watermark_image_var.set(preset.get("image_path", "")) | |
| self.watermark_opacity_var.set(str(preset.get("opacity", "0.80"))) | |
| self.watermark_size_var.set(str(preset.get("font_size", "36"))) | |
| self.watermark_scale_var.set(str(preset.get("image_scale", "25"))) | |
| self.watermark_position_var.set(preset.get("position", "inferior direito")) | |
| self.watermark_x_var.set(str(preset.get("x_percent", ""))) | |
| self.watermark_y_var.set(str(preset.get("y_percent", ""))) | |
| self._toggle_watermark_fields() | |
| self._update_editor_overlay() | |
| self.status_var.set(f"Marca d'agua '{name}' carregada.") | |
| def _delete_selected_preset(self) -> None: | |
| name = self.preset_var.get() | |
| if not name: | |
| messagebox.showwarning("Marca d'agua", "Escolha uma marca salva para excluir.") | |
| return | |
| presets = self._read_presets() | |
| presets.pop(name, None) | |
| try: | |
| self._write_presets(presets) | |
| except OSError as exc: | |
| messagebox.showerror("Marca d'agua", f"Nao foi possivel excluir: {exc}") | |
| return | |
| self._refresh_preset_combo() | |
| self.status_var.set(f"Marca d'agua '{name}' excluida.") | |
| def _enable_drag_and_drop(self) -> None: | |
| if DND_FILES is None: | |
| return | |
| for widget in (self.file_list, self.drop_hint): | |
| try: | |
| widget.drop_target_register(DND_FILES) | |
| widget.dnd_bind("<<Drop>>", self._on_drop) | |
| except tk.TclError: | |
| pass | |
| def _on_drop(self, event: object) -> str: | |
| dropped = getattr(event, "data", "") | |
| paths = [Path(item) for item in self.tk.splitlist(dropped)] | |
| self._add_paths(paths) | |
| return "break" | |
| def _add_paths(self, paths: list[Path]) -> None: | |
| found: list[Path] = [] | |
| for path in paths: | |
| if path.is_dir(): | |
| found.extend(video_files_in_folder(path)) | |
| elif is_video_path(path): | |
| found.append(path) | |
| if not found: | |
| messagebox.showwarning( | |
| "Nenhum video encontrado", | |
| "Escolha arquivos de video ou uma pasta que contenha videos.", | |
| ) | |
| return | |
| self.input_paths = unique_video_paths(self.input_paths + found) | |
| self._refresh_file_list() | |
| if not self.output_dir_var.get().strip(): | |
| self.output_dir_var.set(str(self.input_paths[0].parent)) | |
| self.status_var.set(f"{len(self.input_paths)} video(s) na fila.") | |
| def _refresh_file_list(self) -> None: | |
| self.file_list.delete(0, tk.END) | |
| for path in self.input_paths: | |
| self.file_list.insert(tk.END, str(path)) | |
| def _choose_files(self) -> None: | |
| paths = filedialog.askopenfilenames( | |
| title="Escolha um ou mais videos", | |
| filetypes=[ | |
| ("Videos", "*.mp4 *.mov *.mkv *.avi *.webm *.m4v *.wmv *.flv"), | |
| ("Todos os arquivos", "*.*"), | |
| ], | |
| ) | |
| self._add_paths([Path(path) for path in paths]) | |
| def _choose_input_folder(self) -> None: | |
| path = filedialog.askdirectory(title="Escolha uma pasta com videos") | |
| if path: | |
| self._add_paths([Path(path)]) | |
| def _remove_selected(self) -> None: | |
| selected = set(self.file_list.curselection()) | |
| if not selected: | |
| return | |
| self.input_paths = [ | |
| path for index, path in enumerate(self.input_paths) if index not in selected | |
| ] | |
| self._refresh_file_list() | |
| self.status_var.set(f"{len(self.input_paths)} video(s) na fila.") | |
| def _clear_files(self) -> None: | |
| self.input_paths.clear() | |
| self._refresh_file_list() | |
| self.status_var.set("Fila limpa. Adicione um ou mais videos.") | |
| def _choose_output_folder(self) -> None: | |
| initial = self.output_dir_var.get().strip() or None | |
| path = filedialog.askdirectory(title="Escolha a pasta onde salvar os loopings", initialdir=initial) | |
| if path: | |
| self.output_dir_var.set(path) | |
| def _start(self) -> None: | |
| try: | |
| paths = list(self.input_paths) | |
| output_text = self.output_dir_var.get().strip() | |
| output_parent = Path(output_text).expanduser() if output_text else None | |
| target = float(self.target_var.get().replace(",", ".")) | |
| minimum = float(self.minimum_var.get().replace(",", ".")) | |
| if not paths: | |
| raise VideoLoopError("Adicione pelo menos um video.") | |
| if output_parent is None or not output_parent.is_dir(): | |
| raise VideoLoopError("Escolha uma pasta de saida valida.") | |
| if not math.isfinite(target) or not math.isfinite(minimum): | |
| raise VideoLoopError("As duracoes precisam ser numeros validos.") | |
| if not math.isfinite(target) or not math.isfinite(minimum): | |
| raise VideoLoopError("As duracoes precisam ser numeros validos.") | |
| if target <= 0 or minimum <= 0: | |
| raise VideoLoopError("As duracoes devem ser maiores que zero.") | |
| try: | |
| gif_count = int(self.gif_random_count_var.get()) | |
| except ValueError as exc: | |
| raise VideoLoopError("A quantidade aleatoria de GIFs deve ser inteira.") from exc | |
| width_text = self.gif_width_var.get().strip() | |
| try: | |
| gif_width = int(width_text) if width_text else None | |
| except ValueError as exc: | |
| raise VideoLoopError("A largura do GIF deve ser um numero inteiro.") from exc | |
| if gif_width is not None and gif_width < 1: | |
| raise VideoLoopError("A largura do GIF deve ser maior que zero.") | |
| gif_mode = GIF_MODE_LABELS.get(self.gif_mode_var.get(), "none") | |
| gif_variant = GIF_VARIANT_LABELS.get(self.gif_variant_var.get(), "plain") | |
| gif_selected = set(self.file_list.curselection()) | |
| if gif_mode == "manual" and not gif_selected: | |
| raise VideoLoopError("Selecione na lista os videos que serao convertidos em GIF.") | |
| if gif_mode == "random" and gif_count < 1: | |
| raise VideoLoopError("Informe quantos GIFs aleatorios deseja criar.") | |
| watermark_selected = set(self.file_list.curselection()) | |
| watermark_scope = SCOPE_LABELS.get(self.watermark_scope_var.get(), "all") | |
| if self.create_watermarked_var.get() and watermark_scope == "selected": | |
| if not watermark_selected: | |
| raise VideoLoopError("Selecione os videos que receberao marca d'agua.") | |
| watermark_indices = watermark_selected | |
| else: | |
| watermark_indices = None | |
| watermark = ( | |
| _validate_watermark(self._build_watermark_config()) | |
| if self.create_watermarked_var.get() | |
| else None | |
| ) | |
| if not self.create_plain_var.get() and not self.create_watermarked_var.get(): | |
| raise VideoLoopError("Escolha pelo menos uma versao de video para criar.") | |
| if gif_variant in {"plain", "both"} and gif_mode != "none" and not self.create_plain_var.get(): | |
| raise VideoLoopError("GIF sem marca exige a versao sem marca ativada.") | |
| if gif_variant in {"watermarked", "both"} and gif_mode != "none" and not self.create_watermarked_var.get(): | |
| raise VideoLoopError("GIF com marca exige a versao com marca ativada.") | |
| options = PipelineOptions( | |
| target_seconds=target, | |
| min_loop_seconds=minimum, | |
| prefix=sanitize_prefix(self.prefix_var.get()), | |
| create_plain=self.create_plain_var.get(), | |
| create_watermarked=self.create_watermarked_var.get(), | |
| watermark=watermark, | |
| clean_metadata=self.clean_metadata_var.get(), | |
| gif_mode=gif_mode, | |
| gif_count=gif_count, | |
| gif_variant=gif_variant, | |
| gif_fps=24, | |
| gif_width=gif_width, | |
| skip_existing=self.skip_existing_var.get(), | |
| ) | |
| except ValueError: | |
| messagebox.showerror("Valor invalido", "Digite duracoes como 20 ou 4.5.") | |
| return | |
| except VideoLoopError as exc: | |
| messagebox.showerror("Nao foi possivel iniciar", str(exc)) | |
| return | |
| self.cancel_event.clear() | |
| self.processing = True | |
| self._set_processing_state(True) | |
| self._clear_log() | |
| self.details_var.set("") | |
| self.status_var.set("Iniciando processamento em lote...") | |
| worker = threading.Thread( | |
| target=self._worker, | |
| args=(paths, output_parent, options, gif_selected, watermark_indices), | |
| daemon=True, | |
| ) | |
| worker.start() | |
| def _set_processing_state(self, running: bool) -> None: | |
| state = "disabled" if running else "normal" | |
| for button in self.file_buttons: | |
| button.configure(state=state) | |
| self.output_button.configure(state=state) | |
| self.watermark_preview_button.configure(state=state) | |
| self.process_button.configure(state=state) | |
| self.cancel_button.configure(state="normal" if running else "disabled") | |
| if running: | |
| self.progress.start(10) | |
| else: | |
| self.progress.stop() | |
| def _cancel(self) -> None: | |
| if not self.processing: | |
| return | |
| self.cancel_event.set() | |
| self.cancel_button.configure(state="disabled") | |
| self.status_var.set("Parada solicitada; terminando o video atual...") | |
| def _safe_after(self, func, *args) -> None: | |
| if self.closing: | |
| return | |
| try: | |
| if not self.winfo_exists(): | |
| return | |
| self.after(0, func, *args) | |
| except (tk.TclError, RuntimeError): | |
| pass | |
| def _post_status(self, text: str) -> None: | |
| self._safe_after(self._set_status_and_log, text) | |
| def _set_status_and_log(self, text: str) -> None: | |
| self.status_var.set(text) | |
| self._append_log(text) | |
| def _append_log(self, text: str) -> None: | |
| self.log_text.configure(state="normal") | |
| self.log_text.insert("end", text + "\n") | |
| self.log_text.see("end") | |
| self.log_text.configure(state="disabled") | |
| def _clear_log(self) -> None: | |
| self.log_text.configure(state="normal") | |
| self.log_text.delete("1.0", "end") | |
| self.log_text.configure(state="disabled") | |
| def _worker( | |
| self, | |
| paths: list[Path], | |
| output_parent: Path, | |
| options: PipelineOptions, | |
| gif_selected: set[int], | |
| watermark_indices: Optional[set[int]], | |
| ) -> None: | |
| try: | |
| report = process_pipeline( | |
| paths, | |
| output_parent, | |
| options, | |
| gif_selected_indices=gif_selected, | |
| watermark_indices=watermark_indices, | |
| status=self._post_status, | |
| cancel_event=self.cancel_event, | |
| ) | |
| except Exception as exc: | |
| self._safe_after(self._show_batch_error, str(exc)) | |
| return | |
| self._safe_after(self._show_batch_result, report) | |
| def _show_batch_error(self, message: str) -> None: | |
| self.processing = False | |
| self._set_processing_state(False) | |
| self.status_var.set("Nao foi possivel concluir o processamento.") | |
| self._append_log("ERRO: " + message) | |
| messagebox.showerror("Erro", message) | |
| def _show_batch_result(self, report: PipelineReport) -> None: | |
| self.processing = False | |
| self._set_processing_state(False) | |
| self.last_output_folder = report.output_folder | |
| self.open_folder_button.configure(state="normal") | |
| summary = [ | |
| f"{len(report.created_videos)} video(s) criado(s)", | |
| f"{len(report.created_gifs)} GIF(s) criado(s)", | |
| f"{len(report.skipped)} pulado(s)", | |
| f"{len(report.failures)} falha(s)", | |
| ] | |
| if report.cancelled: | |
| summary.append("cancelado") | |
| self.status_var.set( | |
| f"Processamento concluido. Pasta: {report.output_folder}" | |
| ) | |
| self.details_var.set(" | ".join(summary)) | |
| self._append_log("Resumo: " + " | ".join(summary)) | |
| preview_source = ( | |
| report.created_videos[-1] | |
| if report.created_videos | |
| else (report.outputs[-1] if report.outputs else None) | |
| ) | |
| if preview_source is not None: | |
| self._prepare_preview(preview_source) | |
| if report.failures: | |
| failures = "\n".join( | |
| f"- {path.name}: {message}" for path, message in report.failures[:6] | |
| ) | |
| if len(report.failures) > 6: | |
| failures += "\n- ..." | |
| messagebox.showwarning( | |
| "Processamento parcial", | |
| f"Alguns videos nao foram processados:\n\n{failures}", | |
| ) | |
| elif not report.created_videos and not report.created_gifs: | |
| if report.skipped: | |
| messagebox.showinfo( | |
| "Nada foi recriado", | |
| "Todos os arquivos ja existiam e foram pulados.\n\n" | |
| f"Pasta: {report.loop_folder}\n\n" | |
| "Desmarque 'Pular resultados que ja existem' para recriar.", | |
| ) | |
| else: | |
| messagebox.showwarning( | |
| "Nada foi processado", | |
| "Nenhum arquivo foi criado. Confira a fila de videos e a " | |
| "pasta de saida.", | |
| ) | |
| else: | |
| messagebox.showinfo( | |
| "Pipeline concluido", | |
| f"Videos criados: {len(report.created_videos)}\n" | |
| f"GIFs criados: {len(report.created_gifs)}\n" | |
| f"Ja existiam (pulados): {len(report.skipped)}\n\n" | |
| f"Loopings: {report.loop_folder}\n" | |
| f"GIFs: {report.gif_folder}", | |
| ) | |
| def _clear_preview(self) -> None: | |
| self._stop_preview_playback() | |
| self._set_preview_mode("result") | |
| self.preview_frames = [] | |
| self.preview_index = 0 | |
| self.preview_photo = None | |
| if self.preview_tempdir is not None: | |
| self.preview_tempdir.cleanup() | |
| self.preview_tempdir = None | |
| self.preview_frame_label.configure(image="", text="Nenhum resultado carregado.") | |
| self.preview_status_var.set("O preview aparecera depois do processamento.") | |
| for button in (self.previous_button, self.play_button, self.next_button): | |
| button.configure(state="disabled") | |
| def _prepare_preview(self, output_path: Path) -> None: | |
| self._clear_preview() | |
| self.preview_status_var.set("Gerando preview...") | |
| self.preview_tempdir = tempfile.TemporaryDirectory(prefix="loop_preview_") | |
| preview_folder = Path(self.preview_tempdir.name) | |
| threading.Thread( | |
| target=self._preview_worker, | |
| args=(output_path, preview_folder), | |
| daemon=True, | |
| ).start() | |
| def _preview_worker(self, output_path: Path, preview_folder: Path) -> None: | |
| try: | |
| ffmpeg = _find_executable("ffmpeg") | |
| command = [ | |
| ffmpeg, | |
| "-hide_banner", | |
| "-loglevel", | |
| "error", | |
| "-nostdin", | |
| "-i", | |
| str(output_path), | |
| "-vf", | |
| "fps=5,scale=640:640:force_original_aspect_ratio=decrease", | |
| "-frames:v", | |
| "300", | |
| "-y", | |
| str(preview_folder / "frame_%04d.png"), | |
| ] | |
| result = subprocess.run( | |
| command, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.PIPE, | |
| ) | |
| if result.returncode != 0: | |
| details = result.stderr.decode("utf-8", errors="replace").strip() | |
| raise VideoLoopError(details or "Nao foi possivel criar o preview.") | |
| frames = sorted(preview_folder.glob("frame_*.png")) | |
| if not frames: | |
| raise VideoLoopError("Nenhum quadro foi gerado para o preview.") | |
| except Exception as exc: | |
| self._safe_after(self._preview_failed, str(exc)) | |
| return | |
| self._safe_after(self._preview_ready, frames) | |
| def _preview_failed(self, message: str) -> None: | |
| self.preview_status_var.set(f"Preview indisponivel: {message}") | |
| def _preview_ready(self, frames: list[Path]) -> None: | |
| self.preview_frames = frames | |
| self.preview_index = 0 | |
| for button in (self.previous_button, self.play_button, self.next_button): | |
| button.configure(state="normal") | |
| self._display_preview_frame() | |
| def _display_preview_frame(self) -> None: | |
| if not self.preview_frames: | |
| return | |
| frame_path = self.preview_frames[self.preview_index] | |
| try: | |
| if Image is not None and ImageTk is not None: | |
| with Image.open(frame_path) as image: | |
| image = image.convert("RGB") | |
| image.thumbnail((640, 380)) | |
| self.preview_photo = ImageTk.PhotoImage(image) | |
| else: | |
| self.preview_photo = tk.PhotoImage(file=str(frame_path)) | |
| self.preview_frame_label.configure(image=self.preview_photo, text="") | |
| self.preview_status_var.set( | |
| f"Quadro {self.preview_index + 1} de {len(self.preview_frames)}" | |
| ) | |
| except (OSError, tk.TclError) as exc: | |
| self.preview_status_var.set(f"Nao foi possivel mostrar o preview: {exc}") | |
| def _previous_preview(self) -> None: | |
| if not self.preview_frames: | |
| return | |
| self.preview_index = (self.preview_index - 1) % len(self.preview_frames) | |
| self._display_preview_frame() | |
| def _next_preview(self) -> None: | |
| if not self.preview_frames: | |
| return | |
| self.preview_index = (self.preview_index + 1) % len(self.preview_frames) | |
| self._display_preview_frame() | |
| def _toggle_preview(self) -> None: | |
| if not self.preview_frames: | |
| return | |
| if self.preview_playing: | |
| self._stop_preview_playback() | |
| return | |
| self.preview_playing = True | |
| self.play_button.configure(text="Pausar") | |
| self._play_preview_frame() | |
| def _play_preview_frame(self) -> None: | |
| if not self.preview_playing or not self.preview_frames: | |
| return | |
| self._display_preview_frame() | |
| self.preview_index = (self.preview_index + 1) % len(self.preview_frames) | |
| self.preview_after_id = self.after(200, self._play_preview_frame) | |
| def _stop_preview_playback(self) -> None: | |
| self.preview_playing = False | |
| if self.preview_after_id is not None: | |
| try: | |
| self.after_cancel(self.preview_after_id) | |
| except tk.TclError: | |
| pass | |
| self.preview_after_id = None | |
| if hasattr(self, "play_button"): | |
| self.play_button.configure(text="Reproduzir") | |
| def _open_output_folder(self) -> None: | |
| if self.last_output_folder is None or not self.last_output_folder.exists(): | |
| return | |
| try: | |
| if os.name == "nt": | |
| os.startfile(str(self.last_output_folder)) | |
| elif sys.platform == "darwin": | |
| subprocess.Popen(["open", str(self.last_output_folder)]) | |
| else: | |
| subprocess.Popen(["xdg-open", str(self.last_output_folder)]) | |
| except OSError as exc: | |
| messagebox.showerror("Erro", f"Nao foi possivel abrir a pasta:\n{exc}") | |
| def _on_close(self) -> None: | |
| self.closing = True | |
| self.cancel_event.set() | |
| self._stop_preview_playback() | |
| if self.preview_tempdir is not None: | |
| self.preview_tempdir.cleanup() | |
| self.preview_tempdir = None | |
| self.destroy() | |
| class LoopApp(TkBase): | |
| """Simplified, batch-oriented interface for the complete video pipeline.""" | |
| def __init__(self) -> None: | |
| super().__init__() | |
| self.title("Loop Studio") | |
| self.geometry("1180x800") | |
| self.minsize(980, 700) | |
| self.protocol("WM_DELETE_WINDOW", self._on_close) | |
| self.input_paths: list[Path] = [] | |
| self.output_dir_var = tk.StringVar() | |
| self.target_var = tk.StringVar(value="20") | |
| self.minimum_var = tk.StringVar(value="4") | |
| self.prefix_var = tk.StringVar(value="looping") | |
| self.skip_existing_var = tk.BooleanVar(value=True) | |
| self.create_plain_var = tk.BooleanVar(value=True) | |
| self.create_watermarked_var = tk.BooleanVar(value=False) | |
| self.clean_metadata_var = tk.BooleanVar(value=True) | |
| self.gif_mode_var = tk.StringVar(value="nao criar GIF") | |
| self.gif_variant_var = tk.StringVar(value="sem marca") | |
| self.gif_random_count_var = tk.StringVar(value="1") | |
| self.gif_width_var = tk.StringVar() | |
| self.watermark_mode_var = tk.StringVar(value="text") | |
| self.watermark_text_var = tk.StringVar(value="Python/Tabu") | |
| self.watermark_image_var = tk.StringVar() | |
| self.watermark_opacity_var = tk.StringVar(value="0.80") | |
| self.watermark_size_var = tk.StringVar(value="36") | |
| self.watermark_scale_var = tk.StringVar(value="25") | |
| self.watermark_position_var = tk.StringVar(value="inferior direito") | |
| self.watermark_x_var = tk.StringVar(value="50.0") | |
| self.watermark_y_var = tk.StringVar(value="80.0") | |
| self.watermark_layer_var = tk.StringVar(value="Marca 1") | |
| self.watermark2_enabled_var = tk.BooleanVar(value=False) | |
| self.layer_states = { | |
| "Marca 1": { | |
| "mode": "text", | |
| "text": "Python/Tabu", | |
| "image_path": "", | |
| "opacity": "0.80", | |
| "font_size": "36", | |
| "image_scale": "25", | |
| "position": "inferior direito", | |
| "x_percent": "50.0", | |
| "y_percent": "80.0", | |
| }, | |
| "Marca 2": { | |
| "mode": "text", | |
| "text": "", | |
| "image_path": "", | |
| "opacity": "0.80", | |
| "font_size": "28", | |
| "image_scale": "20", | |
| "position": "superior direito", | |
| "x_percent": "75.0", | |
| "y_percent": "20.0", | |
| }, | |
| } | |
| self.active_layer_name = "Marca 1" | |
| self.preset_var = tk.StringVar() | |
| self.settings_file = Path(__file__).with_name("loop_studio_settings.json") | |
| self.status_var = tk.StringVar(value="Adicione videos para comecar.") | |
| self.details_var = tk.StringVar() | |
| self.preview_status_var = tk.StringVar(value="O primeiro video aparecera aqui.") | |
| self.cancel_event = threading.Event() | |
| self.processing = False | |
| self.closing = False | |
| self.last_output_folder: Optional[Path] = None | |
| self.preview_tempdir: Optional[tempfile.TemporaryDirectory] = None | |
| self.preview_frames: list[Path] = [] | |
| self.preview_index = 0 | |
| self.preview_playing = False | |
| self.preview_after_id: Optional[str] = None | |
| self.preview_photo = None | |
| self.editor_mode = True | |
| self.editor_source_path: Optional[Path] = None | |
| self.editor_clean_image = None | |
| self.editor_exact_image = None | |
| self.editor_render_after_id: Optional[str] = None | |
| self.editor_render_generation = 0 | |
| self.editor_bg_image = None | |
| self.editor_bg_photo = None | |
| self.editor_overlay_photo = None | |
| self.editor_items: list[int] = [] | |
| self.editor_frame_size = (0, 0) | |
| self.editor_display_origin = (0, 0) | |
| self.editor_source_size = (0, 0) | |
| self.presets_file = Path(__file__).with_name("watermark_presets.json") | |
| self._load_default_settings() | |
| self._build_widgets() | |
| self._enable_drag_and_drop() | |
| self._refresh_preset_combo() | |
| def _build_widgets(self) -> None: | |
| shell = ttk.Frame(self, padding=16) | |
| shell.pack(fill="both", expand=True) | |
| header = ttk.Frame(shell) | |
| header.pack(fill="x", pady=(0, 10)) | |
| ttk.Label(header, text="LOOP STUDIO", font=("Segoe UI", 22, "bold")).pack( | |
| side="left" | |
| ) | |
| ttk.Label( | |
| header, | |
| text="loop + marca d'agua + GIF em um so lugar", | |
| foreground="#666666", | |
| ).pack(side="left", padx=(14, 0), pady=(7, 0)) | |
| bottom = ttk.Frame(shell) | |
| bottom.pack(side="bottom", fill="x", pady=(10, 0)) | |
| ttk.Label(bottom, textvariable=self.status_var, anchor="w").pack(fill="x") | |
| ttk.Label(bottom, textvariable=self.details_var, anchor="w").pack(fill="x") | |
| action_bar = ttk.Frame(bottom) | |
| action_bar.pack(fill="x", pady=(7, 0)) | |
| self.progress = ttk.Progressbar(action_bar, mode="indeterminate") | |
| self.progress.pack(side="left", fill="x", expand=True, padx=(0, 10)) | |
| self.cancel_button = ttk.Button( | |
| action_bar, text="Parar", command=self._cancel, state="disabled" | |
| ) | |
| self.cancel_button.pack(side="right", padx=(8, 0)) | |
| self.start_button = tk.Button( | |
| action_bar, | |
| text="COMEÇAR", | |
| command=self._start, | |
| font=("Segoe UI", 14, "bold"), | |
| bg="#2e7d32", | |
| fg="white", | |
| activebackground="#1b5e20", | |
| activeforeground="white", | |
| relief="flat", | |
| padx=34, | |
| pady=9, | |
| cursor="hand2", | |
| ) | |
| self.start_button.pack(side="right") | |
| content = ttk.Frame(shell) | |
| content.pack(fill="both", expand=True) | |
| content.columnconfigure(0, weight=2) | |
| content.columnconfigure(1, weight=3) | |
| content.rowconfigure(0, weight=1) | |
| left = ttk.Frame(content) | |
| left.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) | |
| left.columnconfigure(0, weight=1) | |
| left.rowconfigure(0, weight=1) | |
| files = ttk.LabelFrame(left, text="1. Videos", padding=10) | |
| files.grid(row=0, column=0, sticky="nsew") | |
| files.columnconfigure(0, weight=1) | |
| files.rowconfigure(1, weight=1) | |
| self.drop_hint = ttk.Label( | |
| files, | |
| text=( | |
| "Arraste arquivos/pastas para a lista" | |
| if DND_FILES is not None | |
| else "Adicione arquivos ou uma pasta pelos botoes" | |
| ), | |
| foreground="#666666", | |
| ) | |
| self.drop_hint.grid(row=0, column=0, sticky="w", pady=(0, 6)) | |
| list_frame = ttk.Frame(files) | |
| list_frame.grid(row=1, column=0, sticky="nsew") | |
| list_frame.columnconfigure(0, weight=1) | |
| list_frame.rowconfigure(0, weight=1) | |
| self.file_list = tk.Listbox( | |
| list_frame, | |
| selectmode=tk.EXTENDED, | |
| exportselection=False, | |
| activestyle="none", | |
| font=("Segoe UI", 9), | |
| ) | |
| self.file_list.grid(row=0, column=0, sticky="nsew") | |
| self.file_list.bind("<<ListboxSelect>>", self._on_list_select) | |
| scroll = ttk.Scrollbar(list_frame, orient="vertical", command=self.file_list.yview) | |
| scroll.grid(row=0, column=1, sticky="ns") | |
| self.file_list.configure(yscrollcommand=scroll.set) | |
| file_buttons = ttk.Frame(files) | |
| file_buttons.grid(row=2, column=0, sticky="ew", pady=(8, 0)) | |
| for column in range(4): | |
| file_buttons.columnconfigure(column, weight=1) | |
| self.add_files_button = ttk.Button( | |
| file_buttons, text="+ Arquivos", command=self._choose_files | |
| ) | |
| self.add_folder_button = ttk.Button( | |
| file_buttons, text="+ Pasta", command=self._choose_input_folder | |
| ) | |
| self.remove_button = ttk.Button( | |
| file_buttons, text="Remover", command=self._remove_selected | |
| ) | |
| self.clear_button = ttk.Button(file_buttons, text="Limpar", command=self._clear_files) | |
| for column, button in enumerate( | |
| (self.add_files_button, self.add_folder_button, self.remove_button, self.clear_button) | |
| ): | |
| button.grid(row=0, column=column, sticky="ew", padx=2) | |
| self.file_buttons = [ | |
| self.add_files_button, | |
| self.add_folder_button, | |
| self.remove_button, | |
| self.clear_button, | |
| ] | |
| destination = ttk.LabelFrame(left, text="Destino", padding=8) | |
| destination.grid(row=1, column=0, sticky="ew", pady=(10, 0)) | |
| destination.columnconfigure(1, weight=1) | |
| ttk.Label(destination, text="Pasta:").grid(row=0, column=0, padx=(0, 6)) | |
| self.output_entry = ttk.Entry(destination, textvariable=self.output_dir_var) | |
| self.output_entry.grid(row=0, column=1, sticky="ew") | |
| self.output_button = ttk.Button( | |
| destination, text="Escolher", command=self._choose_output_folder | |
| ) | |
| self.output_button.grid(row=0, column=2, padx=(6, 0)) | |
| ttk.Label(destination, text="cria automaticamente as pastas looping e gif").grid( | |
| row=1, column=0, columnspan=3, sticky="w", pady=(4, 0) | |
| ) | |
| options = ttk.LabelFrame(left, text="2. Opcoes", padding=8) | |
| options.grid(row=2, column=0, sticky="ew", pady=(10, 0)) | |
| options.columnconfigure(1, weight=1) | |
| options.columnconfigure(3, weight=1) | |
| ttk.Label(options, text="Duracao (s):").grid(row=0, column=0, sticky="w") | |
| ttk.Entry(options, textvariable=self.target_var, width=8).grid( | |
| row=0, column=1, sticky="w", padx=(4, 10) | |
| ) | |
| ttk.Label(options, text="Min. loop:").grid(row=0, column=2, sticky="w") | |
| ttk.Entry(options, textvariable=self.minimum_var, width=8).grid( | |
| row=0, column=3, sticky="w", padx=4 | |
| ) | |
| ttk.Label(options, text="Prefixo:").grid(row=1, column=0, sticky="w", pady=(6, 0)) | |
| ttk.Entry(options, textvariable=self.prefix_var).grid( | |
| row=1, column=1, columnspan=3, sticky="ew", padx=(4, 0), pady=(6, 0) | |
| ) | |
| ttk.Label(options, text="GIF:").grid(row=2, column=0, sticky="w", pady=(6, 0)) | |
| self.gif_mode_combo = ttk.Combobox( | |
| options, textvariable=self.gif_mode_var, values=tuple(GIF_MODE_LABELS), | |
| state="readonly", width=15 | |
| ) | |
| self.gif_mode_combo.grid(row=2, column=1, sticky="w", padx=(4, 6), pady=(6, 0)) | |
| self.gif_mode_combo.bind("<<ComboboxSelected>>", self._toggle_gif_fields) | |
| self.gif_variant_combo = ttk.Combobox( | |
| options, textvariable=self.gif_variant_var, values=tuple(GIF_VARIANT_LABELS), | |
| state="readonly", width=13 | |
| ) | |
| self.gif_variant_combo.grid(row=2, column=2, sticky="w", pady=(6, 0)) | |
| ttk.Label(options, text="24 FPS / original").grid(row=2, column=3, sticky="e", pady=(6, 0)) | |
| ttk.Label(options, text="GIF largura:").grid(row=3, column=0, sticky="w", pady=(6, 0)) | |
| self.gif_width_entry = ttk.Entry(options, textvariable=self.gif_width_var, width=8) | |
| self.gif_width_entry.grid(row=3, column=1, sticky="w", padx=(4, 0), pady=(6, 0)) | |
| ttk.Label(options, text="aleatorios:").grid(row=3, column=2, sticky="e", pady=(6, 0)) | |
| self.gif_random_entry = ttk.Entry( | |
| options, textvariable=self.gif_random_count_var, width=6 | |
| ) | |
| self.gif_random_entry.grid(row=3, column=3, sticky="w", padx=(4, 0), pady=(6, 0)) | |
| self.plain_check = ttk.Checkbutton( | |
| options, text="Sem marca", variable=self.create_plain_var | |
| ) | |
| self.plain_check.grid(row=4, column=0, columnspan=2, sticky="w", pady=(8, 0)) | |
| self.watermarked_check = ttk.Checkbutton( | |
| options, text="Com marca", variable=self.create_watermarked_var | |
| ) | |
| self.watermarked_check.grid(row=4, column=2, columnspan=2, sticky="w", pady=(8, 0)) | |
| self.metadata_check = ttk.Checkbutton( | |
| options, text="Limpar metadados", variable=self.clean_metadata_var | |
| ) | |
| self.metadata_check.grid(row=5, column=0, columnspan=2, sticky="w") | |
| self.skip_check = ttk.Checkbutton( | |
| options, text="Pular existentes", variable=self.skip_existing_var | |
| ) | |
| self.skip_check.grid(row=5, column=2, columnspan=2, sticky="w") | |
| ttk.Label( | |
| options, text="A marca ajustada no preview sera aplicada a todos os videos.", | |
| foreground="#666666", | |
| ).grid(row=6, column=0, columnspan=4, sticky="w", pady=(5, 0)) | |
| right = ttk.LabelFrame(content, text="3. Marca d'agua e preview", padding=10) | |
| right.grid(row=0, column=1, sticky="nsew") | |
| right.columnconfigure(0, weight=1) | |
| right.rowconfigure(1, weight=1) | |
| mode_bar = ttk.Frame(right) | |
| mode_bar.grid(row=0, column=0, sticky="ew", pady=(0, 5)) | |
| self.editor_mode_button = ttk.Button( | |
| mode_bar, text="Editar marca d'agua", command=self._show_editor_mode | |
| ) | |
| self.editor_mode_button.pack(side="left") | |
| self.result_mode_button = ttk.Button( | |
| mode_bar, text="Ver resultado", command=self._show_result_mode | |
| ) | |
| self.result_mode_button.pack(side="left", padx=(6, 0)) | |
| self.defaults_button = ttk.Button( | |
| mode_bar, text="Salvar padrao", command=self._save_default_settings | |
| ) | |
| self.defaults_button.pack(side="right") | |
| ttk.Label(mode_bar, text="Camada:").pack(side="right", padx=(8, 4)) | |
| self.layer_combo = ttk.Combobox( | |
| mode_bar, | |
| textvariable=self.watermark_layer_var, | |
| values=("Marca 1", "Marca 2"), | |
| state="readonly", | |
| width=10, | |
| ) | |
| self.layer_combo.pack(side="right") | |
| self.layer_combo.bind("<<ComboboxSelected>>", self._switch_layer) | |
| self.layer2_check = ttk.Checkbutton( | |
| mode_bar, | |
| text="Ativar 2", | |
| variable=self.watermark2_enabled_var, | |
| command=self._update_editor_overlay, | |
| ) | |
| self.layer2_check.pack(side="right", padx=(8, 0)) | |
| preview_area = ttk.Frame(right) | |
| preview_area.grid(row=1, column=0, sticky="nsew") | |
| preview_area.columnconfigure(0, weight=1) | |
| preview_area.rowconfigure(0, weight=1) | |
| self.editor_canvas = tk.Canvas( | |
| preview_area, width=480, height=420, background="#202124", highlightthickness=0 | |
| ) | |
| self.editor_canvas.grid(row=0, column=0, sticky="nsew") | |
| self.editor_canvas.bind("<Button-1>", self._editor_press) | |
| self.editor_canvas.bind("<B1-Motion>", self._editor_drag) | |
| self.editor_canvas.bind("<MouseWheel>", self._editor_wheel) | |
| self.editor_canvas.bind("<Configure>", self._on_editor_resize) | |
| self.preview_frame_label = ttk.Label( | |
| preview_area, text="Nenhum resultado carregado.", anchor="center", justify="center" | |
| ) | |
| self.preview_status_var.set("Adicione videos; o primeiro aparece aqui automaticamente.") | |
| self.preview_frame_label.grid_remove() | |
| watermark_controls = ttk.Frame(right) | |
| watermark_controls.grid(row=2, column=0, sticky="ew", pady=(8, 0)) | |
| watermark_controls.columnconfigure(1, weight=1) | |
| ttk.Label(watermark_controls, text="Texto:").grid(row=0, column=0, sticky="w") | |
| self.watermark_text_entry = ttk.Entry( | |
| watermark_controls, textvariable=self.watermark_text_var | |
| ) | |
| self.watermark_text_entry.grid(row=0, column=1, columnspan=3, sticky="ew", padx=(5, 0)) | |
| ttk.Label(watermark_controls, text="Imagem:").grid(row=1, column=0, sticky="w", pady=(4, 0)) | |
| self.watermark_image_entry = ttk.Entry( | |
| watermark_controls, textvariable=self.watermark_image_var | |
| ) | |
| self.watermark_image_entry.grid(row=1, column=1, columnspan=2, sticky="ew", padx=(5, 0), pady=(4, 0)) | |
| self.watermark_image_button = ttk.Button( | |
| watermark_controls, text="Escolher", command=self._choose_watermark_image | |
| ) | |
| self.watermark_image_button.grid(row=1, column=3, padx=(5, 0), pady=(4, 0)) | |
| ttk.Radiobutton( | |
| watermark_controls, text="Texto", value="text", variable=self.watermark_mode_var, | |
| command=self._toggle_watermark_fields | |
| ).grid(row=2, column=0, sticky="w", pady=(5, 0)) | |
| ttk.Radiobutton( | |
| watermark_controls, text="Imagem", value="image", variable=self.watermark_mode_var, | |
| command=self._toggle_watermark_fields | |
| ).grid(row=2, column=1, sticky="w", pady=(5, 0)) | |
| ttk.Label(watermark_controls, text="Tamanho:").grid(row=2, column=2, sticky="e", pady=(5, 0)) | |
| self.size_scale = ttk.Scale( | |
| watermark_controls, from_=10, to=150, orient="horizontal", command=self._on_size_scale | |
| ) | |
| self.size_scale.grid(row=2, column=3, sticky="ew", padx=(6, 0), pady=(5, 0)) | |
| self.size_value_label = ttk.Label(watermark_controls, text="36", width=5) | |
| self.size_value_label.grid(row=2, column=4, sticky="e", padx=(5, 0), pady=(5, 0)) | |
| ttk.Label(watermark_controls, text="Opacidade:").grid(row=3, column=2, sticky="e") | |
| self.opacity_scale = ttk.Scale( | |
| watermark_controls, from_=5, to=100, orient="horizontal", command=self._on_opacity_scale | |
| ) | |
| self.opacity_scale.grid(row=3, column=3, sticky="ew", padx=(6, 0)) | |
| self.opacity_value_label = ttk.Label(watermark_controls, text="80%", width=5) | |
| self.opacity_value_label.grid(row=3, column=4, sticky="e", padx=(5, 0)) | |
| ttk.Label(watermark_controls, text="Marca salva:").grid(row=4, column=0, sticky="w", pady=(5, 0)) | |
| self.preset_combo = ttk.Combobox( | |
| watermark_controls, textvariable=self.preset_var, state="readonly", width=20 | |
| ) | |
| self.preset_combo.grid(row=4, column=1, sticky="ew", padx=(5, 0), pady=(5, 0)) | |
| self.preset_combo.bind("<<ComboboxSelected>>", self._load_selected_preset) | |
| self.preset_save_button = ttk.Button( | |
| watermark_controls, text="Salvar", command=self._save_preset | |
| ) | |
| self.preset_save_button.grid(row=4, column=2, padx=(6, 0), pady=(5, 0)) | |
| self.preset_delete_button = ttk.Button( | |
| watermark_controls, text="Excluir", command=self._delete_selected_preset | |
| ) | |
| self.preset_delete_button.grid(row=4, column=3, padx=(6, 0), pady=(5, 0)) | |
| for entry in (self.watermark_text_entry, self.watermark_image_entry): | |
| entry.bind("<KeyRelease>", self._update_editor_overlay) | |
| result_controls = ttk.Frame(right) | |
| result_controls.grid(row=3, column=0, sticky="ew", pady=(8, 0)) | |
| self.previous_button = ttk.Button( | |
| result_controls, text="Anterior", command=self._previous_preview, state="disabled" | |
| ) | |
| self.previous_button.pack(side="left", fill="x", expand=True) | |
| self.play_button = ttk.Button( | |
| result_controls, text="Reproduzir", command=self._toggle_preview, state="disabled" | |
| ) | |
| self.play_button.pack(side="left", fill="x", expand=True, padx=5) | |
| self.next_button = ttk.Button( | |
| result_controls, text="Proximo", command=self._next_preview, state="disabled" | |
| ) | |
| self.next_button.pack(side="left", fill="x", expand=True) | |
| self.open_folder_button = ttk.Button( | |
| right, text="Abrir pasta de saida", command=self._open_output_folder, state="disabled" | |
| ) | |
| self.open_folder_button.grid(row=4, column=0, sticky="ew", pady=(6, 0)) | |
| self.editor_controls = [ | |
| self.watermark_text_entry, | |
| self.watermark_image_entry, | |
| self.watermark_image_button, | |
| self.preset_combo, | |
| self.preset_save_button, | |
| self.preset_delete_button, | |
| self.size_scale, | |
| self.opacity_scale, | |
| self.layer_combo, | |
| self.layer2_check, | |
| ] | |
| self._toggle_watermark_fields() | |
| self._toggle_gif_fields() | |
| self._sync_editor_controls() | |
| def _save_active_layer(self, name: Optional[str] = None) -> None: | |
| layer_name = name or getattr(self, "active_layer_name", "Marca 1") | |
| self.layer_states[layer_name] = { | |
| "mode": self.watermark_mode_var.get(), | |
| "text": self.watermark_text_var.get(), | |
| "image_path": self.watermark_image_var.get(), | |
| "opacity": self.watermark_opacity_var.get(), | |
| "font_size": self.watermark_size_var.get(), | |
| "image_scale": self.watermark_scale_var.get(), | |
| "position": self.watermark_position_var.get(), | |
| "x_percent": self.watermark_x_var.get(), | |
| "y_percent": self.watermark_y_var.get(), | |
| } | |
| def _load_active_layer(self) -> None: | |
| state = self.layer_states[self.active_layer_name] | |
| self.watermark_mode_var.set(state["mode"]) | |
| self.watermark_text_var.set(state["text"]) | |
| self.watermark_image_var.set(state["image_path"]) | |
| self.watermark_opacity_var.set(state["opacity"]) | |
| self.watermark_size_var.set(state["font_size"]) | |
| self.watermark_scale_var.set(state["image_scale"]) | |
| self.watermark_position_var.set(state["position"]) | |
| self.watermark_x_var.set(state["x_percent"]) | |
| self.watermark_y_var.set(state["y_percent"]) | |
| self._toggle_watermark_fields() | |
| def _switch_layer(self, _event: object = None) -> None: | |
| old_layer = getattr(self, "active_layer_name", "Marca 1") | |
| self._save_active_layer(old_layer) | |
| self.active_layer_name = self.watermark_layer_var.get() | |
| self._load_active_layer() | |
| def _state_to_config(self, state: dict) -> WatermarkConfig: | |
| try: | |
| x_percent = float(state["x_percent"].replace(",", ".")) | |
| y_percent = float(state["y_percent"].replace(",", ".")) | |
| opacity = float(state["opacity"].replace(",", ".")) | |
| font_size = int(state["font_size"]) | |
| image_scale = int(state["image_scale"]) | |
| except (KeyError, TypeError, ValueError) as exc: | |
| raise VideoLoopError("A configuracao de uma marca d'agua esta invalida.") from exc | |
| image_text = state.get("image_path", "").strip() | |
| return WatermarkConfig( | |
| mode=state.get("mode", "text"), | |
| text=state.get("text", ""), | |
| image_path=Path(image_text) if image_text else None, | |
| opacity=opacity, | |
| font_size=font_size, | |
| image_scale=image_scale, | |
| position=POSITION_LABELS.get(state.get("position", "inferior direito"), "bottom-right"), | |
| x_percent=x_percent, | |
| y_percent=y_percent, | |
| ) | |
| def _build_watermark_configs(self) -> tuple[WatermarkConfig, ...]: | |
| self._save_active_layer() | |
| configs = [self._state_to_config(self.layer_states["Marca 1"])] | |
| if self.watermark2_enabled_var.get(): | |
| configs.append(self._state_to_config(self.layer_states["Marca 2"])) | |
| return tuple(_validate_watermark(config) for config in configs) | |
| def _toggle_watermark_fields(self) -> None: | |
| text_state = "normal" if self.watermark_mode_var.get() == "text" else "disabled" | |
| image_state = "normal" if self.watermark_mode_var.get() == "image" else "disabled" | |
| self.watermark_text_entry.configure(state=text_state) | |
| self.watermark_image_entry.configure(state=image_state) | |
| self.watermark_image_button.configure(state=image_state) | |
| self._sync_editor_controls() | |
| self._update_editor_overlay() | |
| def _toggle_gif_fields(self, _event: object = None) -> None: | |
| mode = GIF_MODE_LABELS.get(self.gif_mode_var.get(), "none") | |
| self.gif_random_entry.configure(state="normal" if mode == "random" else "disabled") | |
| enabled = mode != "none" | |
| self.gif_variant_combo.configure(state="readonly" if enabled else "disabled") | |
| self.gif_width_entry.configure(state="normal" if enabled else "disabled") | |
| def _sync_editor_controls(self) -> None: | |
| image_mode = self.watermark_mode_var.get() == "image" | |
| if image_mode: | |
| self.size_scale.configure(from_=5, to=80) | |
| try: | |
| value = float(self.watermark_scale_var.get()) | |
| except ValueError: | |
| value = 25 | |
| self.size_scale.set(min(80, max(5, value))) | |
| self.size_value_label.configure(text=f"{int(value)}%") | |
| else: | |
| self.size_scale.configure(from_=10, to=150) | |
| try: | |
| value = float(self.watermark_size_var.get()) | |
| except ValueError: | |
| value = 36 | |
| self.size_scale.set(min(150, max(10, value))) | |
| self.size_value_label.configure(text=str(int(value))) | |
| try: | |
| opacity = float(self.watermark_opacity_var.get().replace(",", ".")) * 100 | |
| except ValueError: | |
| opacity = 80 | |
| opacity = min(100, max(5, opacity)) | |
| self.opacity_scale.set(opacity) | |
| self.opacity_value_label.configure(text=f"{int(opacity)}%") | |
| def _on_size_scale(self, value: str) -> None: | |
| numeric = int(float(value)) | |
| if self.watermark_mode_var.get() == "image": | |
| self.watermark_scale_var.set(str(numeric)) | |
| self.size_value_label.configure(text=f"{numeric}%") | |
| else: | |
| self.watermark_size_var.set(str(numeric)) | |
| self.size_value_label.configure(text=str(numeric)) | |
| self._update_editor_overlay() | |
| def _on_opacity_scale(self, value: str) -> None: | |
| numeric = min(100, max(5, int(float(value)))) | |
| self.watermark_opacity_var.set(f"{numeric / 100:.2f}") | |
| self.opacity_value_label.configure(text=f"{numeric}%") | |
| self._update_editor_overlay() | |
| def _choose_watermark_image(self) -> None: | |
| path = filedialog.askopenfilename( | |
| title="Escolha a imagem da marca d'agua", | |
| filetypes=[("Imagens", "*.png *.jpg *.jpeg *.webp *.bmp"), ("Todos", "*.*")], | |
| ) | |
| if path: | |
| self.watermark_image_var.set(path) | |
| self.watermark_mode_var.set("image") | |
| self._toggle_watermark_fields() | |
| def _editor_wheel(self, event: object) -> None: | |
| step = 4 if self.watermark_mode_var.get() == "text" else 2 | |
| direction = 1 if getattr(event, "delta", 0) > 0 else -1 | |
| if self.watermark_mode_var.get() == "image": | |
| current = self._editor_image_scale() | |
| self.watermark_scale_var.set(str(min(80, max(5, current + direction * step // 2)))) | |
| else: | |
| try: | |
| current = int(self.watermark_size_var.get()) | |
| except ValueError: | |
| current = 36 | |
| self.watermark_size_var.set(str(min(150, max(10, current + direction * step)))) | |
| self._sync_editor_controls() | |
| self._update_editor_overlay() | |
| def _show_editor_mode(self) -> None: | |
| self._stop_preview_playback() | |
| self._set_preview_mode("editor") | |
| if self.editor_source_path is None and self.input_paths: | |
| self._load_editor_frame(self.input_paths[0]) | |
| elif not self.input_paths: | |
| self.preview_status_var.set("Adicione videos para editar a marca d'agua.") | |
| def _show_result_mode(self) -> None: | |
| if not self.preview_frames: | |
| self.preview_status_var.set("Nenhum resultado ainda. Clique em COMEÇAR primeiro.") | |
| return | |
| self._set_preview_mode("result") | |
| self._display_preview_frame() | |
| def _build_watermark_config(self) -> WatermarkConfig: | |
| try: | |
| opacity = float(self.watermark_opacity_var.get().replace(",", ".")) | |
| font_size = int(self.watermark_size_var.get()) | |
| image_scale = int(self.watermark_scale_var.get()) | |
| x_percent = float(self.watermark_x_var.get().replace(",", ".")) | |
| y_percent = float(self.watermark_y_var.get().replace(",", ".")) | |
| except ValueError as exc: | |
| raise VideoLoopError( | |
| "Tamanho, opacidade e posicao da marca precisam ser numericos." | |
| ) from exc | |
| return WatermarkConfig( | |
| mode=self.watermark_mode_var.get(), | |
| text=self.watermark_text_var.get(), | |
| image_path=Path(self.watermark_image_var.get().strip()) | |
| if self.watermark_image_var.get().strip() | |
| else None, | |
| opacity=opacity, | |
| font_size=font_size, | |
| image_scale=image_scale, | |
| position=POSITION_LABELS.get(self.watermark_position_var.get(), "bottom-right"), | |
| x_percent=x_percent, | |
| y_percent=y_percent, | |
| ) | |
| def _load_editor_frame(self, input_path: Path) -> None: | |
| if self.processing or ( | |
| self.editor_source_path == input_path and self.editor_bg_photo is not None | |
| ): | |
| return | |
| try: | |
| source_info = probe_video(input_path) | |
| except VideoLoopError as exc: | |
| self.preview_status_var.set(str(exc)) | |
| return | |
| self.editor_render_generation += 1 | |
| if self.editor_render_after_id is not None: | |
| try: | |
| self.after_cancel(self.editor_render_after_id) | |
| except tk.TclError: | |
| pass | |
| self.editor_render_after_id = None | |
| self.editor_source_path = input_path | |
| self._set_preview_mode("editor") | |
| self.editor_canvas.delete("all") | |
| self.editor_items = [] | |
| self.editor_clean_image = None | |
| self.editor_exact_image = None | |
| self.editor_bg_photo = None | |
| self.editor_overlay_photo = None | |
| if self.preview_tempdir is not None: | |
| self.preview_tempdir.cleanup() | |
| self.preview_tempdir = tempfile.TemporaryDirectory(prefix="watermark_editor_") | |
| frame_path = Path(self.preview_tempdir.name) / "frame.png" | |
| self.preview_status_var.set(f"Carregando preview de {input_path.name}...") | |
| threading.Thread( | |
| target=self._editor_worker, | |
| args=(input_path, frame_path, source_info), | |
| daemon=True, | |
| ).start() | |
| def _editor_worker( | |
| self, input_path: Path, frame_path: Path, source_info: VideoInfo | |
| ) -> None: | |
| try: | |
| extract_frame(input_path, frame_path, at_seconds=0.5, max_side=560) | |
| except Exception as exc: | |
| self._safe_after(self._preview_failed, str(exc)) | |
| return | |
| self._safe_after(self._editor_frame_ready, frame_path, source_info) | |
| def _editor_frame_ready(self, frame_path: Path, source_info: VideoInfo) -> None: | |
| if Image is None or ImageTk is None: | |
| self.preview_status_var.set("Instale Pillow para usar o editor visual.") | |
| return | |
| try: | |
| with Image.open(frame_path) as image: | |
| background_image = image.convert("RGB").copy() | |
| except (OSError, tk.TclError) as exc: | |
| self.preview_status_var.set(f"Nao foi possivel carregar o preview: {exc}") | |
| return | |
| self.editor_clean_image = background_image | |
| self.editor_exact_image = None | |
| self.editor_bg_image = background_image | |
| self.editor_source_size = (source_info.width or 1, source_info.height or 1) | |
| self._sync_editor_controls() | |
| self._on_editor_resize() | |
| self._schedule_exact_editor_preview() | |
| self.preview_status_var.set( | |
| "Arraste a marca; rode a rodinha do mouse para aumentar ou diminuir." | |
| ) | |
| def _on_editor_resize(self, _event: object = None) -> None: | |
| source_image = self.editor_exact_image or self.editor_clean_image or self.editor_bg_image | |
| if not self.editor_mode or source_image is None or ImageTk is None: | |
| return | |
| canvas_width = max(1, self.editor_canvas.winfo_width()) | |
| canvas_height = max(1, self.editor_canvas.winfo_height()) | |
| image = source_image.copy() | |
| resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1) | |
| image.thumbnail((canvas_width, canvas_height), resampling) | |
| origin_x = max(0, (canvas_width - image.width) // 2) | |
| origin_y = max(0, (canvas_height - image.height) // 2) | |
| self.editor_display_origin = (origin_x, origin_y) | |
| self.editor_frame_size = (image.width, image.height) | |
| self.editor_bg_photo = ImageTk.PhotoImage(image) | |
| self.editor_canvas.delete("all") | |
| self.editor_items = [ | |
| self.editor_canvas.create_image(origin_x, origin_y, image=self.editor_bg_photo, anchor="nw") | |
| ] | |
| if self.editor_exact_image is None: | |
| self._draw_editor_overlay_pil() | |
| def _set_preview_mode(self, mode: str) -> None: | |
| self.editor_mode = mode == "editor" | |
| if self.editor_mode: | |
| self.preview_frame_label.grid_remove() | |
| self.editor_canvas.grid(row=0, column=0, sticky="nsew") | |
| else: | |
| self.editor_canvas.grid_remove() | |
| self.preview_frame_label.grid(row=0, column=0, sticky="nsew") | |
| def _editor_opacity(self) -> int: | |
| try: | |
| value = float(self.watermark_opacity_var.get().replace(",", ".")) | |
| except ValueError: | |
| value = 0.8 | |
| return int(min(1.0, max(0.05, value)) * 255) | |
| def _editor_image_scale(self) -> int: | |
| try: | |
| return min(80, max(5, int(self.watermark_scale_var.get()))) | |
| except ValueError: | |
| return 25 | |
| def _update_editor_overlay(self, _event: object = None) -> None: | |
| if not self.editor_mode or self.editor_clean_image is None or Image is None or ImageTk is None: | |
| return | |
| self.editor_exact_image = None | |
| self._on_editor_resize() | |
| self._schedule_exact_editor_preview() | |
| def _draw_editor_overlay_pil(self) -> None: | |
| if not self.editor_mode or self.editor_clean_image is None or Image is None or ImageTk is None: | |
| return | |
| self._save_active_layer() | |
| frame_w, frame_h = self.editor_frame_size | |
| overlay = Image.new("RGBA", (frame_w, frame_h), (0, 0, 0, 0)) | |
| for layer_name in ("Marca 1", "Marca 2"): | |
| if layer_name == "Marca 2" and not self.watermark2_enabled_var.get(): | |
| continue | |
| state = self.layer_states[layer_name] | |
| try: | |
| fx = min(1.0, max(0.0, float(state["x_percent"]) / 100.0)) | |
| fy = min(1.0, max(0.0, float(state["y_percent"]) / 100.0)) | |
| opacity = int(min(1.0, max(0.05, float(state["opacity"]))) * 255) | |
| except (KeyError, TypeError, ValueError): | |
| continue | |
| if state["mode"] == "text": | |
| self._draw_editor_text(overlay, state, fx, fy, opacity) | |
| else: | |
| self._draw_editor_image(overlay, state, fx, fy, opacity) | |
| self.editor_overlay_photo = ImageTk.PhotoImage(overlay) | |
| for item in self.editor_items[1:]: | |
| self.editor_canvas.delete(item) | |
| self.editor_items = self.editor_items[:1] | |
| self.editor_items.append( | |
| self.editor_canvas.create_image( | |
| self.editor_display_origin[0], | |
| self.editor_display_origin[1], | |
| image=self.editor_overlay_photo, | |
| anchor="nw", | |
| ) | |
| ) | |
| def _draw_editor_text(self, overlay, state: dict, fx: float, fy: float, alpha: int) -> None: | |
| if ImageDraw is None or ImageFont is None: | |
| return | |
| draw = ImageDraw.Draw(overlay) | |
| frame_w, frame_h = self.editor_frame_size | |
| factor = frame_w / max(1, self.editor_source_size[0]) | |
| try: | |
| size = max(6, int(float(state["font_size"]) * factor)) | |
| except ValueError: | |
| size = max(6, int(36 * factor)) | |
| font = ImageFont.load_default() | |
| font_path = _default_font_file() | |
| if font_path: | |
| try: | |
| font = ImageFont.truetype(str(font_path), size) | |
| except OSError: | |
| pass | |
| draw.multiline_text( | |
| (fx * frame_w, fy * frame_h), | |
| state.get("text", "") or " ", | |
| font=font, | |
| fill=(255, 255, 255, alpha), | |
| anchor="mm", | |
| align="center", | |
| stroke_width=max(1, int(size / 18)), | |
| stroke_fill=(0, 0, 0, int(alpha * 0.55)), | |
| ) | |
| def _draw_editor_image(self, overlay, state: dict, fx: float, fy: float, alpha: int) -> None: | |
| path_text = state.get("image_path", "").strip() | |
| if not path_text or Image is None: | |
| return | |
| try: | |
| stamp = Image.open(path_text).convert("RGBA") | |
| except OSError: | |
| return | |
| frame_w, frame_h = self.editor_frame_size | |
| target_w = max(8, int(frame_w * min(80, max(5, int(state["image_scale"]))) / 100)) | |
| target_h = max(1, int(stamp.height * target_w / max(1, stamp.width))) | |
| stamp = stamp.resize((target_w, target_h)) | |
| stamp.putalpha(stamp.getchannel("A").point(lambda value: value * alpha // 255)) | |
| x = int(min(max(0, frame_w - stamp.width), fx * frame_w - stamp.width / 2)) | |
| y = int(min(max(0, frame_h - stamp.height), fy * frame_h - stamp.height / 2)) | |
| overlay.alpha_composite(stamp, (x, y)) | |
| def _schedule_exact_editor_preview(self) -> None: | |
| if not self.editor_source_path or not self.editor_mode: | |
| return | |
| self.editor_render_generation += 1 | |
| generation = self.editor_render_generation | |
| if self.editor_render_after_id is not None: | |
| try: | |
| self.after_cancel(self.editor_render_after_id) | |
| except tk.TclError: | |
| pass | |
| self.editor_render_after_id = self.after( | |
| 180, self._start_exact_editor_preview, generation | |
| ) | |
| def _start_exact_editor_preview(self, generation: int) -> None: | |
| if generation != self.editor_render_generation or self.editor_source_path is None: | |
| return | |
| try: | |
| configs = self._build_watermark_configs() | |
| except VideoLoopError as exc: | |
| self.preview_status_var.set(str(exc)) | |
| return | |
| if self.preview_tempdir is None: | |
| self.preview_tempdir = tempfile.TemporaryDirectory(prefix="watermark_editor_") | |
| output_path = Path(self.preview_tempdir.name) / f"exact_{generation}.png" | |
| threading.Thread( | |
| target=self._exact_editor_preview_worker, | |
| args=(self.editor_source_path, output_path, configs, generation), | |
| daemon=True, | |
| ).start() | |
| def _exact_editor_preview_worker( | |
| self, | |
| input_path: Path, | |
| output_path: Path, | |
| configs: tuple[WatermarkConfig, ...], | |
| generation: int, | |
| ) -> None: | |
| try: | |
| render_watermark_preview(input_path, output_path, configs) | |
| except Exception as exc: | |
| self._safe_after(self._preview_failed, str(exc)) | |
| return | |
| self._safe_after(self._exact_editor_preview_ready, output_path, generation) | |
| def _exact_editor_preview_ready(self, output_path: Path, generation: int) -> None: | |
| if generation != self.editor_render_generation or Image is None: | |
| return | |
| try: | |
| with Image.open(output_path) as image: | |
| self.editor_exact_image = image.convert("RGB").copy() | |
| except OSError as exc: | |
| self.preview_status_var.set(f"Preview da marca: {exc}") | |
| return | |
| self._on_editor_resize() | |
| self.preview_status_var.set( | |
| "Preview exato do FFmpeg: arraste ou use a rodinha para ajustar." | |
| ) | |
| def _editor_press(self, event: object) -> None: | |
| self._editor_drag(event) | |
| def _editor_drag(self, event: object) -> None: | |
| if not self.editor_mode or not self.editor_frame_size[0]: | |
| return | |
| width, height = self.editor_frame_size | |
| local_x = event.x - self.editor_display_origin[0] | |
| local_y = event.y - self.editor_display_origin[1] | |
| self.watermark_x_var.set(f"{min(100, max(0, local_x / width * 100)):.1f}") | |
| self.watermark_y_var.set(f"{min(100, max(0, local_y / height * 100)):.1f}") | |
| self._update_editor_overlay() | |
| def _load_default_settings(self) -> None: | |
| if not self.settings_file.is_file(): | |
| return | |
| try: | |
| settings = json.loads(self.settings_file.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| return | |
| if not isinstance(settings, dict): | |
| return | |
| simple_values = { | |
| "target": self.target_var, | |
| "minimum": self.minimum_var, | |
| "prefix": self.prefix_var, | |
| "gif_mode": self.gif_mode_var, | |
| "gif_variant": self.gif_variant_var, | |
| "gif_random_count": self.gif_random_count_var, | |
| "gif_width": self.gif_width_var, | |
| } | |
| for key, variable in simple_values.items(): | |
| if key in settings: | |
| variable.set(str(settings[key])) | |
| boolean_values = { | |
| "skip_existing": self.skip_existing_var, | |
| "create_plain": self.create_plain_var, | |
| "create_watermarked": self.create_watermarked_var, | |
| "clean_metadata": self.clean_metadata_var, | |
| "layer2_enabled": self.watermark2_enabled_var, | |
| } | |
| for key, variable in boolean_values.items(): | |
| if key in settings: | |
| variable.set(bool(settings[key])) | |
| for layer_name in ("Marca 1", "Marca 2"): | |
| saved = settings.get("layers", {}).get(layer_name) | |
| if isinstance(saved, dict): | |
| self.layer_states[layer_name].update(saved) | |
| def _save_default_settings(self) -> None: | |
| self._save_active_layer() | |
| settings = { | |
| "target": self.target_var.get(), | |
| "minimum": self.minimum_var.get(), | |
| "prefix": self.prefix_var.get(), | |
| "gif_mode": self.gif_mode_var.get(), | |
| "gif_variant": self.gif_variant_var.get(), | |
| "gif_random_count": self.gif_random_count_var.get(), | |
| "gif_width": self.gif_width_var.get(), | |
| "skip_existing": self.skip_existing_var.get(), | |
| "create_plain": self.create_plain_var.get(), | |
| "create_watermarked": self.create_watermarked_var.get(), | |
| "clean_metadata": self.clean_metadata_var.get(), | |
| "layer2_enabled": self.watermark2_enabled_var.get(), | |
| "layers": self.layer_states, | |
| } | |
| try: | |
| self.settings_file.write_text( | |
| json.dumps(settings, ensure_ascii=False, indent=2), encoding="utf-8" | |
| ) | |
| self.status_var.set("Configuracao padrao salva para a proxima abertura.") | |
| except OSError as exc: | |
| messagebox.showerror("Configuracao", str(exc)) | |
| def _read_presets(self) -> dict: | |
| if not self.presets_file.is_file(): | |
| return {} | |
| try: | |
| data = json.loads(self.presets_file.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| return {} | |
| return data if isinstance(data, dict) else {} | |
| def _write_presets(self, presets: dict) -> None: | |
| self.presets_file.write_text( | |
| json.dumps(presets, ensure_ascii=False, indent=2), encoding="utf-8" | |
| ) | |
| def _refresh_preset_combo(self) -> None: | |
| presets = self._read_presets() | |
| self.preset_combo.configure(values=tuple(sorted(presets))) | |
| if self.preset_var.get() not in presets: | |
| self.preset_var.set("") | |
| def _save_preset(self) -> None: | |
| name = (simpledialog.askstring("Salvar marca", "Nome:", parent=self) or "").strip() | |
| if not name: | |
| return | |
| self._save_active_layer() | |
| presets = self._read_presets() | |
| presets[name] = { | |
| "layers": self.layer_states, | |
| "layer2_enabled": self.watermark2_enabled_var.get(), | |
| } | |
| try: | |
| self._write_presets(presets) | |
| except OSError as exc: | |
| messagebox.showerror("Marca d'agua", str(exc)) | |
| return | |
| self._refresh_preset_combo() | |
| self.preset_var.set(name) | |
| self.status_var.set(f"Marca '{name}' salva.") | |
| def _load_selected_preset(self, _event: object = None) -> None: | |
| name = self.preset_var.get() | |
| preset = self._read_presets().get(name) | |
| if not preset: | |
| return | |
| if "layers" in preset: | |
| for layer_name in ("Marca 1", "Marca 2"): | |
| if isinstance(preset["layers"].get(layer_name), dict): | |
| self.layer_states[layer_name].update(preset["layers"][layer_name]) | |
| self.watermark2_enabled_var.set(bool(preset.get("layer2_enabled", False))) | |
| else: | |
| # Import presets created by the one-watermark version. | |
| self.layer_states["Marca 1"].update( | |
| { | |
| "mode": preset.get("mode", "text"), | |
| "text": preset.get("text", ""), | |
| "image_path": preset.get("image_path", ""), | |
| "opacity": str(preset.get("opacity", "0.80")), | |
| "font_size": str(preset.get("font_size", "36")), | |
| "image_scale": str(preset.get("image_scale", "25")), | |
| "position": preset.get("position", "inferior direito"), | |
| "x_percent": str(preset.get("x_percent") or "50"), | |
| "y_percent": str(preset.get("y_percent") or "80"), | |
| } | |
| ) | |
| self.active_layer_name = "Marca 1" | |
| self.watermark_layer_var.set("Marca 1") | |
| self._load_active_layer() | |
| self.status_var.set(f"Marca '{name}' carregada.") | |
| def _delete_selected_preset(self) -> None: | |
| name = self.preset_var.get() | |
| if not name: | |
| return | |
| presets = self._read_presets() | |
| presets.pop(name, None) | |
| self._write_presets(presets) | |
| self._refresh_preset_combo() | |
| def _enable_drag_and_drop(self) -> None: | |
| if DND_FILES is None: | |
| return | |
| for widget in (self.file_list, self.drop_hint): | |
| try: | |
| widget.drop_target_register(DND_FILES) | |
| widget.dnd_bind("<<Drop>>", self._on_drop) | |
| except (tk.TclError, AttributeError): | |
| pass | |
| def _on_drop(self, event: object) -> str: | |
| self._add_paths([Path(item) for item in self.tk.splitlist(getattr(event, "data", ""))]) | |
| return "break" | |
| def _add_paths(self, paths: list[Path]) -> None: | |
| found: list[Path] = [] | |
| for path in paths: | |
| if path.is_dir(): | |
| found.extend(video_files_in_folder(path)) | |
| elif is_video_path(path): | |
| found.append(path) | |
| if not found: | |
| messagebox.showwarning("Videos", "Nenhum video encontrado.") | |
| return | |
| self.input_paths = unique_video_paths(self.input_paths + found) | |
| self._refresh_file_list() | |
| if not self.output_dir_var.get().strip(): | |
| self.output_dir_var.set(str(self.input_paths[0].parent)) | |
| self.status_var.set(f"{len(self.input_paths)} video(s) na fila.") | |
| if self.editor_source_path is None: | |
| self._load_editor_frame(self.input_paths[0]) | |
| def _on_list_select(self, _event: object = None) -> None: | |
| selected = self.file_list.curselection() | |
| if selected and not self.processing: | |
| self._load_editor_frame(self.input_paths[selected[0]]) | |
| def _refresh_file_list(self) -> None: | |
| self.file_list.delete(0, tk.END) | |
| for path in self.input_paths: | |
| self.file_list.insert(tk.END, str(path)) | |
| def _choose_files(self) -> None: | |
| paths = filedialog.askopenfilenames( | |
| title="Escolha videos", | |
| filetypes=[("Videos", "*.mp4 *.mov *.mkv *.avi *.webm *.m4v *.wmv *.flv"), ("Todos", "*.*")], | |
| ) | |
| self._add_paths([Path(path) for path in paths]) | |
| def _choose_input_folder(self) -> None: | |
| path = filedialog.askdirectory(title="Escolha uma pasta com videos") | |
| if path: | |
| self._add_paths([Path(path)]) | |
| def _remove_selected(self) -> None: | |
| selected = set(self.file_list.curselection()) | |
| self.input_paths = [path for index, path in enumerate(self.input_paths) if index not in selected] | |
| self._refresh_file_list() | |
| self.status_var.set(f"{len(self.input_paths)} video(s) na fila.") | |
| def _clear_files(self) -> None: | |
| self.input_paths.clear() | |
| self.editor_source_path = None | |
| self.editor_canvas.delete("all") | |
| self.editor_items = [] | |
| self._refresh_file_list() | |
| self.status_var.set("Fila limpa.") | |
| def _choose_output_folder(self) -> None: | |
| initial = self.output_dir_var.get().strip() or None | |
| path = filedialog.askdirectory(title="Escolha a pasta de saida", initialdir=initial) | |
| if path: | |
| self.output_dir_var.set(path) | |
| def _start(self) -> None: | |
| try: | |
| paths = list(self.input_paths) | |
| output_text = self.output_dir_var.get().strip() | |
| output_parent = Path(output_text).expanduser() if output_text else None | |
| target = float(self.target_var.get().replace(",", ".")) | |
| minimum = float(self.minimum_var.get().replace(",", ".")) | |
| if not paths: | |
| raise VideoLoopError("Adicione pelo menos um video.") | |
| if output_parent is None or not output_parent.is_dir(): | |
| raise VideoLoopError("Escolha uma pasta de saida valida.") | |
| if not math.isfinite(target) or not math.isfinite(minimum): | |
| raise VideoLoopError("As duracoes precisam ser numeros validos.") | |
| if target <= 0 or minimum <= 0: | |
| raise VideoLoopError("As duracoes devem ser maiores que zero.") | |
| gif_mode = GIF_MODE_LABELS.get(self.gif_mode_var.get(), "none") | |
| gif_selected = set(self.file_list.curselection()) | |
| gif_count = int(self.gif_random_count_var.get()) | |
| gif_width_text = self.gif_width_var.get().strip() | |
| gif_width = int(gif_width_text) if gif_width_text else None | |
| if gif_mode == "manual" and not gif_selected: | |
| raise VideoLoopError("Selecione os videos que serao GIFs.") | |
| if gif_mode == "random" and gif_count < 1: | |
| raise VideoLoopError("Informe a quantidade de GIFs aleatorios.") | |
| watermarks = ( | |
| self._build_watermark_configs() | |
| if self.create_watermarked_var.get() | |
| else () | |
| ) | |
| if not self.create_plain_var.get() and not self.create_watermarked_var.get(): | |
| raise VideoLoopError("Marque Sem marca, Com marca ou ambos.") | |
| gif_variant = GIF_VARIANT_LABELS.get(self.gif_variant_var.get(), "plain") | |
| if gif_mode != "none" and gif_variant in {"watermarked", "both"} and not self.create_watermarked_var.get(): | |
| raise VideoLoopError("Ative a versao Com marca para criar esse GIF.") | |
| options = PipelineOptions( | |
| target_seconds=target, | |
| min_loop_seconds=minimum, | |
| prefix=sanitize_prefix(self.prefix_var.get()), | |
| create_plain=self.create_plain_var.get(), | |
| create_watermarked=self.create_watermarked_var.get(), | |
| watermark=watermarks[0] if watermarks else None, | |
| clean_metadata=self.clean_metadata_var.get(), | |
| gif_mode=gif_mode, | |
| gif_count=gif_count, | |
| gif_variant=gif_variant, | |
| gif_fps=24, | |
| gif_width=gif_width, | |
| skip_existing=self.skip_existing_var.get(), | |
| watermarks=watermarks, | |
| ) | |
| except (ValueError, VideoLoopError) as exc: | |
| messagebox.showerror("Nao foi possivel iniciar", str(exc)) | |
| return | |
| self.cancel_event.clear() | |
| self.processing = True | |
| self._set_processing_state(True) | |
| self.details_var.set("") | |
| self.status_var.set("Processando... acompanhe o video atual acima.") | |
| threading.Thread( | |
| target=self._worker, | |
| args=(paths, output_parent, options, gif_selected), | |
| daemon=True, | |
| ).start() | |
| def _set_processing_state(self, running: bool) -> None: | |
| state = "disabled" if running else "normal" | |
| for button in self.file_buttons: | |
| button.configure(state=state) | |
| self.output_button.configure(state=state) | |
| self.defaults_button.configure(state=state) | |
| self.start_button.configure(state="disabled" if running else "normal") | |
| self.cancel_button.configure(state="normal" if running else "disabled") | |
| for widget in self.editor_controls: | |
| try: | |
| widget.configure(state="disabled" if running else "normal") | |
| except tk.TclError: | |
| pass | |
| if running: | |
| self.progress.start(10) | |
| else: | |
| self.progress.stop() | |
| def _cancel(self) -> None: | |
| if self.processing: | |
| self.cancel_event.set() | |
| self.cancel_button.configure(state="disabled") | |
| self.status_var.set("Parada solicitada; terminando o video atual...") | |
| def _safe_after(self, func, *args) -> None: | |
| if self.closing: | |
| return | |
| try: | |
| if self.winfo_exists(): | |
| self.after(0, func, *args) | |
| except (tk.TclError, RuntimeError): | |
| pass | |
| def _post_status(self, text: str) -> None: | |
| self._safe_after(self.status_var.set, text) | |
| def _worker( | |
| self, | |
| paths: list[Path], | |
| output_parent: Path, | |
| options: PipelineOptions, | |
| gif_selected: set[int], | |
| ) -> None: | |
| try: | |
| report = process_pipeline( | |
| paths, | |
| output_parent, | |
| options, | |
| gif_selected_indices=gif_selected, | |
| watermark_indices=None, | |
| cancel_event=self.cancel_event, | |
| status=self._post_status, | |
| ) | |
| except Exception as exc: | |
| self._safe_after(self._show_batch_error, str(exc)) | |
| return | |
| self._safe_after(self._show_batch_result, report) | |
| def _show_batch_error(self, message: str) -> None: | |
| self.processing = False | |
| self._set_processing_state(False) | |
| self.status_var.set("Erro durante o processamento.") | |
| messagebox.showerror("Erro", message) | |
| def _show_batch_result(self, report: PipelineReport) -> None: | |
| self.processing = False | |
| self._set_processing_state(False) | |
| self.last_output_folder = report.output_folder | |
| self.open_folder_button.configure(state="normal") | |
| self.details_var.set( | |
| f"{len(report.created_videos)} video(s) | {len(report.created_gifs)} GIF(s) | " | |
| f"{len(report.skipped)} pulado(s) | {len(report.failures)} falha(s)" | |
| ) | |
| if report.created_videos or report.outputs: | |
| self._prepare_preview((report.created_videos or report.outputs)[-1]) | |
| if report.failures: | |
| details = "\n".join(f"{path.name}: {error}" for path, error in report.failures[:5]) | |
| messagebox.showwarning("Processamento parcial", details) | |
| elif not report.created_videos and not report.created_gifs and report.skipped: | |
| messagebox.showinfo( | |
| "Nada recriado", | |
| "Os arquivos ja existiam. Desmarque 'Pular existentes' para recriar.", | |
| ) | |
| else: | |
| messagebox.showinfo( | |
| "Concluido", | |
| f"Videos criados: {len(report.created_videos)}\n" | |
| f"GIFs criados: {len(report.created_gifs)}\n\n" | |
| f"Pasta: {report.output_folder}", | |
| ) | |
| def _clear_preview(self) -> None: | |
| self._stop_preview_playback() | |
| self.preview_frames = [] | |
| self.preview_index = 0 | |
| self.preview_photo = None | |
| for button in (self.previous_button, self.play_button, self.next_button): | |
| button.configure(state="disabled") | |
| self.preview_frame_label.configure(image="", text="Nenhum resultado carregado.") | |
| def _prepare_preview(self, output_path: Path) -> None: | |
| self._clear_preview() | |
| self._set_preview_mode("result") | |
| if self.preview_tempdir is not None: | |
| self.preview_tempdir.cleanup() | |
| self.preview_tempdir = tempfile.TemporaryDirectory(prefix="loop_preview_") | |
| folder = Path(self.preview_tempdir.name) | |
| self.preview_status_var.set("Gerando preview do resultado...") | |
| threading.Thread( | |
| target=self._preview_worker, args=(output_path, folder), daemon=True | |
| ).start() | |
| def _preview_worker(self, output_path: Path, folder: Path) -> None: | |
| try: | |
| ffmpeg = _find_executable("ffmpeg") | |
| command = [ | |
| ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", | |
| "-i", str(output_path), "-vf", | |
| "fps=5,scale=640:640:force_original_aspect_ratio=decrease", | |
| "-frames:v", "300", "-y", str(folder / "frame_%04d.png"), | |
| ] | |
| result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) | |
| if result.returncode != 0: | |
| raise VideoLoopError(result.stderr.decode("utf-8", errors="replace").strip()) | |
| frames = sorted(folder.glob("frame_*.png")) | |
| if not frames: | |
| raise VideoLoopError("Nenhum quadro de preview foi gerado.") | |
| except Exception as exc: | |
| self._safe_after(self._preview_failed, str(exc)) | |
| return | |
| self._safe_after(self._preview_ready, frames) | |
| def _preview_failed(self, message: str) -> None: | |
| self.preview_status_var.set(f"Preview indisponivel: {message}") | |
| def _preview_ready(self, frames: list[Path]) -> None: | |
| self.preview_frames = frames | |
| self.preview_index = 0 | |
| for button in (self.previous_button, self.play_button, self.next_button): | |
| button.configure(state="normal") | |
| self._display_preview_frame() | |
| def _display_preview_frame(self) -> None: | |
| if not self.preview_frames: | |
| return | |
| try: | |
| path = self.preview_frames[self.preview_index] | |
| if Image is not None and ImageTk is not None: | |
| with Image.open(path) as image: | |
| image = image.convert("RGB") | |
| image.thumbnail((700, 520)) | |
| self.preview_photo = ImageTk.PhotoImage(image) | |
| else: | |
| self.preview_photo = tk.PhotoImage(file=str(path)) | |
| self.preview_frame_label.configure(image=self.preview_photo, text="") | |
| self.preview_status_var.set( | |
| f"Resultado: quadro {self.preview_index + 1}/{len(self.preview_frames)}" | |
| ) | |
| except (OSError, tk.TclError) as exc: | |
| self.preview_status_var.set(str(exc)) | |
| def _previous_preview(self) -> None: | |
| if self.preview_frames: | |
| self.preview_index = (self.preview_index - 1) % len(self.preview_frames) | |
| self._display_preview_frame() | |
| def _next_preview(self) -> None: | |
| if self.preview_frames: | |
| self.preview_index = (self.preview_index + 1) % len(self.preview_frames) | |
| self._display_preview_frame() | |
| def _toggle_preview(self) -> None: | |
| if not self.preview_frames: | |
| return | |
| if self.preview_playing: | |
| self._stop_preview_playback() | |
| else: | |
| self.preview_playing = True | |
| self.play_button.configure(text="Pausar") | |
| self._play_preview_frame() | |
| def _play_preview_frame(self) -> None: | |
| if not self.preview_playing or not self.preview_frames: | |
| return | |
| self._display_preview_frame() | |
| self.preview_index = (self.preview_index + 1) % len(self.preview_frames) | |
| self.preview_after_id = self.after(200, self._play_preview_frame) | |
| def _stop_preview_playback(self) -> None: | |
| self.preview_playing = False | |
| if self.preview_after_id is not None: | |
| try: | |
| self.after_cancel(self.preview_after_id) | |
| except tk.TclError: | |
| pass | |
| self.preview_after_id = None | |
| if hasattr(self, "play_button"): | |
| self.play_button.configure(text="Reproduzir") | |
| def _open_output_folder(self) -> None: | |
| if self.last_output_folder is None or not self.last_output_folder.exists(): | |
| return | |
| try: | |
| if os.name == "nt": | |
| os.startfile(str(self.last_output_folder)) | |
| elif sys.platform == "darwin": | |
| subprocess.Popen(["open", str(self.last_output_folder)]) | |
| else: | |
| subprocess.Popen(["xdg-open", str(self.last_output_folder)]) | |
| except OSError as exc: | |
| messagebox.showerror("Erro", str(exc)) | |
| def _on_close(self) -> None: | |
| self.closing = True | |
| self.cancel_event.set() | |
| self._stop_preview_playback() | |
| if self.preview_tempdir is not None: | |
| self.preview_tempdir.cleanup() | |
| self._save_default_settings() | |
| self.destroy() | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| description="Encontra um loop visual em um video e o repete ate a duracao desejada." | |
| ) | |
| parser.add_argument("-i", "--input", type=Path, help="video de entrada") | |
| parser.add_argument("-o", "--output", type=Path, help="arquivo de saida") | |
| parser.add_argument( | |
| "-t", | |
| "--target", | |
| type=float, | |
| default=DEFAULT_TARGET_SECONDS, | |
| help="duracao minima desejada para a saida, em segundos (padrao: 20)", | |
| ) | |
| parser.add_argument( | |
| "-m", | |
| "--min-loop", | |
| type=float, | |
| default=DEFAULT_MIN_LOOP_SECONDS, | |
| help="duracao minima permitida para o loop, em segundos (padrao: 4)", | |
| ) | |
| parser.add_argument( | |
| "--analyze-only", | |
| action="store_true", | |
| help="apenas mostra o loop encontrado, sem renderizar a saida", | |
| ) | |
| return parser | |
| def run_cli(args: argparse.Namespace) -> int: | |
| if args.input is None: | |
| LoopApp().mainloop() | |
| return 0 | |
| if ( | |
| not math.isfinite(args.target) | |
| or not math.isfinite(args.min_loop) | |
| or args.target <= 0 | |
| or args.min_loop <= 0 | |
| ): | |
| print("target e min-loop devem ser maiores que zero.", file=sys.stderr) | |
| return 2 | |
| input_path = args.input | |
| output_path = args.output or default_output_path(input_path) | |
| try: | |
| info, candidate, fps = analyze_video( | |
| input_path, | |
| args.min_loop, | |
| status=lambda text: print(text, flush=True), | |
| ) | |
| print( | |
| f"Loop encontrado: inicio {candidate.start:.2f}s, " | |
| f"duracao {candidate.duration:.2f}s (analise em {fps:.1f} fps)." | |
| ) | |
| if args.analyze_only: | |
| return 0 | |
| repeats, output_duration = render_loop( | |
| input_path, | |
| output_path, | |
| candidate, | |
| args.target, | |
| status=lambda text: print(text, flush=True), | |
| ) | |
| except VideoLoopError as exc: | |
| print(f"Erro: {exc}", file=sys.stderr) | |
| return 1 | |
| print(f"Saida: {output_path}") | |
| print(f"Repeticoes: {repeats}; duracao final: {output_duration:.2f}s") | |
| return 0 | |
| def main() -> int: | |
| return run_cli(build_parser().parse_args()) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |