converter video (mp4) em gif

翻译

converter video (mp4) em gif

# -*- coding: utf-8 -*-
# Addon Conversor de Vídeo para GIF - Anki v1.0
# Baseado no Modificador de Formatação v1.7
#
# O que faz:
# - Você seleciona um ou mais decks (e opcionalmente campos específicos)
# - Ao clicar em "Converter", o addon varre as notas em busca de vídeos
# (mp4, webm, avi, mov, mkv, flv, wmv, m4v, mpg, mpeg, 3gp) referenciados
# via [sound:arquivo.mp4] ou via tags
# - Cada vídeo é convertido para GIF usando o ffmpeg (precisa estar instalado
# e acessível no PATH do sistema, ou você pode indicar o caminho manualmente)
# - O GIF resultante é adicionado à coleção de mídia do Anki e a referência
# no campo é substituída por
# - O vídeo original NÃO é apagado da pasta de mídia (fica intacto)
# - Vídeos repetidos (mesmo arquivo usado em várias notas) são convertidos
# apenas uma vez na mesma execução (cache em memória)
import os
import re
import shutil
import subprocess
import tempfile
from aqt import mw
from aqt.qt import *
from aqt.utils import showInfo, showWarning, askUser
from aqt.operations import CollectionOp, OpChanges
VIDEO_EXTENSIONS = [
"mp4", "webm", "avi", "mov", "mkv", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp"
]
_EXT_ALTERNATION = "|".join(VIDEO_EXTENSIONS)
# [sound:arquivo.mp4]
PATTERN_SOUND = re.compile(
r"\[sound:([^\[\]]+?\.(?:" + _EXT_ALTERNATION + r"))\]", re.IGNORECASE
)
# blocos (incluindo internos)
PATTERN_VIDEO_BLOCK = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL)
# extrai o primeiro src de um bloco
PATTERN_SRC_ATTR = re.compile(
r'src=["\']([^"\']+?\.(?:' + _EXT_ALTERNATION + r'))["\']', re.IGNORECASE
)
ADDON_DIR = os.path.dirname(os.path.abspath(__file__))
def find_ffmpeg():
"""Tenta localizar o executável do ffmpeg.
Ordem de busca:
1. Dentro da própria pasta do addon (ffmpeg.exe no Windows, ffmpeg no Linux/Mac)
2. No PATH do sistema
3. Locais comuns de instalação
"""
candidatos_locais = [
os.path.join(ADDON_DIR, "ffmpeg.exe"),
os.path.join(ADDON_DIR, "ffmpeg"),
os.path.join(ADDON_DIR, "bin", "ffmpeg.exe"),
os.path.join(ADDON_DIR, "bin", "ffmpeg"),
]
for c in candidatos_locais:
if os.path.isfile(c):
return c
caminho = shutil.which("ffmpeg")
if caminho:
return caminho
candidatos_sistema = [
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg",
"C:\\ffmpeg\\bin\\ffmpeg.exe",
]
for c in candidatos_sistema:
if os.path.isfile(c):
return c
return None
class ConversorVideoParaGif:
"""Faz a conversão de arquivos de vídeo para GIF usando ffmpeg, com cache."""
def __init__(self, ffmpeg_path, media_dir, fps=10, largura=480):
self.ffmpeg_path = ffmpeg_path
self.media_dir = media_dir
self.fps = fps
self.largura = largura
self.cache = {} # nome_arquivo_original -> nome_arquivo_gif_final (ou None se falhou)
self.erros = []
def converter(self, col, nome_arquivo_video):
"""Converte um vídeo (nome de arquivo já presente na pasta de mídia) para GIF.
Retorna o nome final do arquivo GIF adicionado à coleção, ou None em caso de erro."""
if nome_arquivo_video in self.cache:
return self.cache[nome_arquivo_video]
caminho_video = os.path.join(self.media_dir, nome_arquivo_video)
if not os.path.isfile(caminho_video):
self.erros.append(f"Arquivo não encontrado na mídia: {nome_arquivo_video}")
self.cache[nome_arquivo_video] = None
return None
pasta_tmp = tempfile.mkdtemp(prefix="anki_v2gif_")
try:
palette_path = os.path.join(pasta_tmp, "palette.png")
gif_tmp_path = os.path.join(
pasta_tmp, os.path.splitext(os.path.basename(nome_arquivo_video))[0] + ".gif"
)
filtro_base = f"fps={self.fps},scale={self.largura}:-1:flags=lanczos"
cmd_palette = [
self.ffmpeg_path, "-y", "-i", caminho_video,
"-vf", f"{filtro_base},palettegen",
palette_path,
]
r1 = subprocess.run(cmd_palette, capture_output=True, timeout=180)
if r1.returncode != 0 or not os.path.isfile(palette_path):
self.erros.append(
f"Falha ao gerar paleta para {nome_arquivo_video}: "
f"{r1.stderr.decode(errors='ignore')[-300:]}"
)
self.cache[nome_arquivo_video] = None
return None
cmd_gif = [
self.ffmpeg_path, "-y", "-i", caminho_video, "-i", palette_path,
"-filter_complex", f"{filtro_base}[x];[x][1:v]paletteuse",
gif_tmp_path,
]
r2 = subprocess.run(cmd_gif, capture_output=True, timeout=180)
if r2.returncode != 0 or not os.path.isfile(gif_tmp_path):
self.erros.append(
f"Falha ao converter {nome_arquivo_video}: "
f"{r2.stderr.decode(errors='ignore')[-300:]}"
)
self.cache[nome_arquivo_video] = None
return None
# Adiciona à coleção de mídia (Anki cuida de renomear em caso de conflito)
nome_final = col.media.add_file(gif_tmp_path)
self.cache[nome_arquivo_video] = nome_final
return nome_final
except subprocess.TimeoutExpired:
self.erros.append(f"Tempo esgotado ao converter {nome_arquivo_video}")
self.cache[nome_arquivo_video] = None
return None
except Exception as e:
self.erros.append(f"Erro inesperado em {nome_arquivo_video}: {e}")
self.cache[nome_arquivo_video] = None
return None
finally:
shutil.rmtree(pasta_tmp, ignore_errors=True)
def processar_campo_html(html, col, conversor):
"""Procura vídeos no HTML do campo e substitui pelas tags do GIF gerado.
Retorna (html_modificado, houve_mudanca: bool)."""
modificado = False
# 1) Blocos
def repl_video_block(match):
nonlocal modificado
bloco = match.group(0)
m_src = PATTERN_SRC_ATTR.search(bloco)
if not m_src:
return bloco
nome_video = m_src.group(1)
nome_gif = conversor.converter(col, nome_video)
if nome_gif is None:
return bloco
modificado = True
return f''
html = PATTERN_VIDEO_BLOCK.sub(repl_video_block, html)
# 2) [sound:arquivo.mp4]
def repl_sound(match):
nonlocal modificado
nome_video = match.group(1)
nome_gif = conversor.converter(col, nome_video)
if nome_gif is None:
return match.group(0)
modificado = True
return f''
html = PATTERN_SOUND.sub(repl_sound, html)
return html, modificado
class DialogoConversorGif(QDialog):
def __init__(self, parent=None):
super(DialogoConversorGif, self).__init__(parent)
self.setWindowTitle("Conversor de Vídeo para GIF v1.0")
self.setMinimumWidth(600)
self.notas_modificadas_contagem = 0
self.videos_convertidos_contagem = 0
self.erros_conversao = []
layout = QVBoxLayout(self)
aviso_ffmpeg = QLabel()
self.ffmpeg_path = find_ffmpeg()
if self.ffmpeg_path:
if os.path.dirname(os.path.abspath(self.ffmpeg_path)) == ADDON_DIR:
aviso_ffmpeg.setText(f"✅ ffmpeg encontrado na pasta do addon: {self.ffmpeg_path}")
else:
aviso_ffmpeg.setText(f"✅ ffmpeg encontrado em: {self.ffmpeg_path}")
else:
aviso_ffmpeg.setText(
"⚠️ ffmpeg não encontrado automaticamente. Coloque o ffmpeg.exe na pasta "
"do addon, instale-o no PATH, ou selecione o executável abaixo."
)
aviso_ffmpeg.setWordWrap(True)
layout.addWidget(aviso_ffmpeg)
botao_localizar_ffmpeg = QPushButton("Selecionar executável do ffmpeg manualmente...")
botao_localizar_ffmpeg.clicked.connect(lambda: self.selecionar_ffmpeg(aviso_ffmpeg))
layout.addWidget(botao_localizar_ffmpeg)
# --- Seção 1: Decks ---
decks_group = QGroupBox("1. Selecione os Decks (use Ctrl ou Shift para selecionar vários)")
decks_layout = QVBoxLayout()
self.campo_pesquisa_deck = QLineEdit()
self.campo_pesquisa_deck.setPlaceholderText("Pesquisar deck...")
self.campo_pesquisa_deck.textChanged.connect(self.filtrar_decks)
decks_layout.addWidget(self.campo_pesquisa_deck)
self.lista_decks = QListWidget()
self.lista_decks.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.todos_os_decks = sorted(mw.col.decks.all_names(), key=str.lower)
self.lista_decks.addItems(self.todos_os_decks)
decks_layout.addWidget(self.lista_decks)
decks_group.setLayout(decks_layout)
layout.addWidget(decks_group)
# --- Seção 2: Campos ---
campos_group = QGroupBox(
"2. Selecione os Campos a verificar (deixe vazio para verificar TODOS os campos)"
)
campos_layout = QVBoxLayout()
self.lista_campos = QListWidget()
self.lista_campos.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
campos_layout.addWidget(self.lista_campos)
campos_group.setLayout(campos_layout)
layout.addWidget(campos_group)
# --- Seção 3: Opções de conversão ---
opcoes_group = QGroupBox("3. Opções de conversão")
opcoes_layout = QHBoxLayout()
opcoes_layout.addWidget(QLabel("FPS do GIF:"))
self.spin_fps = QSpinBox()
self.spin_fps.setRange(1, 30)
self.spin_fps.setValue(10)
opcoes_layout.addWidget(self.spin_fps)
opcoes_layout.addWidget(QLabel("Largura (px):"))
self.spin_largura = QSpinBox()
self.spin_largura.setRange(100, 1280)
self.spin_largura.setSingleStep(20)
self.spin_largura.setValue(480)
opcoes_layout.addWidget(self.spin_largura)
opcoes_layout.addStretch()
opcoes_group.setLayout(opcoes_layout)
layout.addWidget(opcoes_group)
# --- Botões de ação ---
botoes = QDialogButtonBox()
self.undo_button = botoes.addButton("Desfazer Última Modificação", QDialogButtonBox.ButtonRole.ActionRole)
self.cancel_button = botoes.addButton("Fechar", QDialogButtonBox.ButtonRole.RejectRole)
self.start_button = botoes.addButton("Converter", QDialogButtonBox.ButtonRole.AcceptRole)
layout.addWidget(botoes)
self.start_button.clicked.connect(self.iniciar_conversao)
self.cancel_button.clicked.connect(self.reject)
self.undo_button.clicked.connect(self.desfazer_acao)
self.lista_decks.itemSelectionChanged.connect(self.atualizar_lista_campos)
self.setLayout(layout)
self.atualizar_estado_botao_desfazer()
def selecionar_ffmpeg(self, label_widget):
caminho, _ = QFileDialog.getOpenFileName(self, "Selecione o executável do ffmpeg")
if caminho:
self.ffmpeg_path = caminho
label_widget.setText(f"✅ ffmpeg definido manualmente: {self.ffmpeg_path}")
def filtrar_decks(self, texto):
texto = texto.strip().lower()
selecionados_antes = {item.text() for item in self.lista_decks.selectedItems()}
self.lista_decks.blockSignals(True)
self.lista_decks.clear()
for nome in self.todos_os_decks:
if texto in nome.lower():
item = QListWidgetItem(nome)
self.lista_decks.addItem(item)
if nome in selecionados_antes:
item.setSelected(True)
self.lista_decks.blockSignals(False)
self.atualizar_lista_campos()
def atualizar_lista_campos(self):
decks_selecionados = [item.text() for item in self.lista_decks.selectedItems()]
self.lista_campos.clear()
if not decks_selecionados:
return
query = " or ".join(f'"deck:{name}"' for name in decks_selecionados)
ids_notas = mw.col.find_notes(query)
if not ids_notas:
return
campos_encontrados = set()
for nid in ids_notas[:500]:
try:
nota = mw.col.get_note(nid)
modelo = mw.col.models.get(nota.mid)
campos_encontrados.update(mw.col.models.field_names(modelo))
except Exception:
continue
self.lista_campos.addItems(sorted(list(campos_encontrados)))
def set_buttons_enabled(self, enabled: bool):
self.start_button.setEnabled(enabled)
self.undo_button.setEnabled(enabled)
def atualizar_estado_botao_desfazer(self):
self.undo_button.setEnabled(True)
undo_name = mw.col.undo_name()
if undo_name:
self.undo_button.setToolTip(f"Clique para desfazer a última ação: '{undo_name}'")
else:
self.undo_button.setToolTip("Nenhuma ação para desfazer no momento.")
def desfazer_acao(self):
undo_name = mw.col.undo_name()
if undo_name:
mw.col.undo()
mw.reset()
showInfo(f"Ação '{undo_name}' foi desfeita.", parent=self)
self.atualizar_estado_botao_desfazer()
else:
showWarning("Não há nenhuma ação para desfazer.", parent=self)
def on_conversion_finished(self, _changes: OpChanges):
self.set_buttons_enabled(True)
self.atualizar_estado_botao_desfazer()
msg = (
f"Conversão concluída!\n\n"
f"Notas alteradas: {self.notas_modificadas_contagem}\n"
f"Vídeos convertidos para GIF: {self.videos_convertidos_contagem}"
)
if self.erros_conversao:
amostras = "\n".join(f"- {e}" for e in self.erros_conversao[:10])
msg += f"\n\n{len(self.erros_conversao)} erro(s) durante a conversão:\n{amostras}"
showInfo(msg, parent=self)
def iniciar_conversao(self):
decks_selecionados = [item.text() for item in self.lista_decks.selectedItems()]
campos_selecionados = [item.text() for item in self.lista_campos.selectedItems()]
if not decks_selecionados:
showWarning("Nenhum deck foi selecionado.", parent=self)
return
if not self.ffmpeg_path or not (
shutil.which(self.ffmpeg_path) or os.path.isfile(self.ffmpeg_path)
):
showWarning(
"ffmpeg não foi localizado. Instale o ffmpeg ou selecione o "
"executável manualmente antes de converter.",
parent=self,
)
return
alvo_campos = "todos os campos" if not campos_selecionados else ", ".join(campos_selecionados)
msg = (
f"Converter vídeos (mp4, webm, avi, mov, etc.) para GIF nos decks selecionados, "
f"verificando {alvo_campos}?

"
f"Isso pode demorar dependendo da quantidade e duração dos vídeos.
"
f"Esta ação poderá ser revertida pelo botão 'Desfazer'."
)
if not askUser(msg, title="Confirmação Final", parent=self):
return
self.set_buttons_enabled(False)
op = CollectionOp(
parent=self,
op=lambda col: self.backend_conversao(
col, decks_selecionados, campos_selecionados,
self.spin_fps.value(), self.spin_largura.value(),
),
)
op.success(self.on_conversion_finished)
op.run_in_background()
def backend_conversao(self, col, decks, campos_selecionados, fps, largura) -> OpChanges:
self.notas_modificadas_contagem = 0
self.videos_convertidos_contagem = 0
self.erros_conversao = []
conversor = ConversorVideoParaGif(
ffmpeg_path=self.ffmpeg_path,
media_dir=col.media.dir(),
fps=fps,
largura=largura,
)
query = " or ".join(f'"deck:{name}"' for name in decks)
ids_notas = col.find_notes(query)
for nid in ids_notas:
try:
nota = col.get_note(nid)
modelo = col.models.get(nota.mid)
nomes_dos_campos = col.models.field_names(modelo)
nota_modificada = False
for i, valor_campo in enumerate(nota.fields):
nome_campo_atual = nomes_dos_campos[i]
if campos_selecionados and nome_campo_atual not in campos_selecionados:
continue
novo_valor, mudou = processar_campo_html(valor_campo, col, conversor)
if mudou:
nota.fields[i] = novo_valor
nota_modificada = True
if nota_modificada:
col.update_note(nota)
self.notas_modificadas_contagem += 1
except Exception as e:
self.erros_conversao.append(f"Erro na nota {nid}: {e}")
continue
self.videos_convertidos_contagem = sum(
1 for v in conversor.cache.values() if v is not None
)
self.erros_conversao.extend(conversor.erros)
return OpChanges()
def abrir_dialogo_conversor():
if not hasattr(mw, "conversor_gif_dialog") or not mw.conversor_gif_dialog.isVisible():
mw.conversor_gif_dialog = DialogoConversorGif(mw)
mw.conversor_gif_dialog.show()
else:
mw.conversor_gif_dialog.raise_()
mw.conversor_gif_dialog.activateWindow()
acao = QAction("Conversor de Vídeo para GIF...", mw)
mw.form.menuTools.addAction(acao)
acao.triggered.connect(abrir_dialogo_conversor)
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论