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) |
评论
?
参与讨论