diff --git a/.jhonny/analises.db b/.jhonny/analises.db index 6ffc55b..0e88469 100644 Binary files a/.jhonny/analises.db and b/.jhonny/analises.db differ diff --git a/admin/deploy.command b/admin/deploy.command index 4242ff7..7fc09e5 100755 --- a/admin/deploy.command +++ b/admin/deploy.command @@ -27,6 +27,7 @@ BRANCH="main" GRN="$(tput setaf 2 2>/dev/null)"; YLW="$(tput setaf 3 2>/dev/null)" RD="$(tput setaf 1 2>/dev/null)"; BLU="$(tput setaf 4 2>/dev/null)" +BD="$(tput bold 2>/dev/null)" NC="$(tput sgr0 2>/dev/null)" say() { echo "${BLU}== ${NC}$*"; } ok() { echo "${GRN}-> OK ${NC}$*"; } @@ -99,7 +100,8 @@ else BODY="(sem nota em DEV-NOTES.md)" fi -COMMIT_MSG="feat: $(echo "$BODY" | head -1 | cut -c1-50) +ASSUNTO="$(echo "$BODY" | head -1 | sed 's/^[[:space:]]*[-*][[:space:]]*//' | cut -c1-60)" +COMMIT_MSG="feat: $ASSUNTO $BODY @@ -114,10 +116,12 @@ $STAT Arquivos: $FILES" -say "Mensagem do commit:" -echo "----------------------------------------------" +echo +echo "${BD}${YLW}============================================================${NC}" +echo "${BD}${YLW} MENSAGEM QUE SERÁ GRAVADA NO COMMIT${NC}" +echo "${BD}${YLW}============================================================${NC}" echo "$COMMIT_MSG" -echo "----------------------------------------------" +echo "${BD}${YLW}============================================================${NC}" echo if [[ "${DRY:-0}" = "1" ]]; then diff --git a/code/engine/executar_scanner.py b/code/engine/executar_scanner.py index a0a2f57..ffe902c 100644 --- a/code/engine/executar_scanner.py +++ b/code/engine/executar_scanner.py @@ -15,13 +15,41 @@ CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent if str(CAMINHO_DO_CODIGO) not in sys.path: sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +def _encontrar_caminho_da_midia() -> str: + """Localiza a pasta com ``ffmpeg``/``ffprobe`` para o PATH dos subprocessos. + + O Premiere é aberto pelo Finder/LaunchServices (sem shell de login), então o + PATH herdado pelo painel não costuma trazer ``/opt/homebrew/bin``. Essa + função devolve o primeiro caminho conhecido onde essas ferramentas existem, + ou string vazia quando nenhum é encontrado. + """ + for candidato in ("/opt/homebrew/bin", "/usr/local/bin"): + if Path(candidato, "ffprobe").is_file() or Path(candidato, "ffmpeg").is_file(): + return candidato + return "" + + +# O mesmo ajuste de PATH já usado em ``admin/deploy.command`` e +# ``admin/install.command``: sem ele, a extração de frames e a transcrição +# falham com ``No such file or directory: 'ffprobe'``. +CAMINHO_DA_MIDIA = _encontrar_caminho_da_midia() +if CAMINHO_DA_MIDIA: + os.environ["PATH"] = CAMINHO_DA_MIDIA + os.pathsep + os.environ.get("PATH", "") + from engine.integracoes.premiere.conversores import ConversorDeTimeline from engine.scanner import ConfiguracaoVisualDoScanner, criar_detector_apple_vision, gerar_relatorio_visual from engine.scanner.transcricao_da_timeline import TranscricaoDaTimeline +from engine.persistencia import RepositorioDeAnalisesSQLite, RepositorioDeTimelineSQLite MODELO_DIARIZACAO_PADRAO = "pyannote/speaker-diarization-community-1" +# O padrão do banco de análises é ``.jhonny/analises.db`` relativo ao cwd, mas o +# painel roda o scanner com cwd ``code``. Fixamos o caminho absoluto na raiz do +# projeto para garantir que a gravação sempre caia no mesmo banco. +CAMINHO_DO_BANCO = CAMINHO_DO_CODIGO.parent / ".jhonny" / "analises.db" + def nome_seguro(nome: str) -> str: return re.sub(r"[^A-Za-z0-9._-]+", "-", nome.strip()).strip(".-") or "timeline" @@ -41,6 +69,13 @@ def executar(entrada: Path, saida: Path) -> Path: print(json.dumps({"evento": "progresso", "etapa": etapa, "percentual": round(percentual, 1), "clipe": clipe}, ensure_ascii=False), flush=True) + # Persistência: timeline/estrutura + análise visual + transcrição vão para o + # banco SQLite do projeto (mesmo dos relatórios gerados). + repositorio_de_timeline = RepositorioDeTimelineSQLite(CAMINHO_DO_BANCO) + repositorio_de_analises = RepositorioDeAnalisesSQLite(CAMINHO_DO_BANCO) + emitir("Persistindo timeline", 0) + repositorio_de_timeline.registrar_timeline(timeline) + resultados = [] for clipe in clipes: peso = max(1, math.ceil((clipe.intervalo_na_origem or clipe.intervalo_na_timeline).duracao / @@ -60,6 +95,11 @@ def executar(entrada: Path, saida: Path) -> Path: arquivo = Path(tempfile.gettempdir()) / f"scanner-{clipe.identificador}.json" gerar_relatorio_visual(clipe, resultado, arquivo, configuracao.intervalo_em_segundos) resultados.append(json.loads(arquivo.read_text(encoding="utf-8"))) + # Grava no banco os fatos visuais e as cenas detectadas deste clipe. + repositorio_de_analises.registrar_evidencias_visuais( + timeline.identificador, clipe.identificador, resultado.evidencias) + repositorio_de_analises.registrar_cenas( + timeline.identificador, resultado.cenas, clipe.identificador) except Exception as erro: resultados.append({"clipe": {"identificador": clipe.identificador, "nome": clipe.nome, "arquivo_original": clipe.arquivo}, "erro": str(erro)}) @@ -111,6 +151,8 @@ def executar(entrada: Path, saida: Path) -> Path: transcricoes = TranscricaoDaTimeline(provider, diretoria_de_trabalho=pasta / ".cache-audio", diarizador=diarizador).executar( Timeline(timeline.identificador, timeline.nome, faixas=faixas_de_audio)) + # Persiste a transcrição (segmentos + falas) no banco de análises. + repositorio_de_analises.registrar_transcricoes(timeline.identificador, transcricoes) for transcricao in transcricoes: transcricoes_por_clipe[transcricao.identificador_do_clipe] = [ {"inicio": item.inicio, "fim": item.fim, "texto": item.texto, diff --git a/code/engine/integracoes/midia/__init__.py b/code/engine/integracoes/midia/__init__.py index 1be4bf4..f6fd5ce 100644 --- a/code/engine/integracoes/midia/__init__.py +++ b/code/engine/integracoes/midia/__init__.py @@ -1,4 +1,8 @@ +from .compactador_de_proxy import (CompactadorDeProxy, ExtratorDeQuadrosDoProxy, + RESOLUCAO_PADRAO_DO_PROXY) from .extracao_de_audio import ExtracaoDeAudio, ParteDeAudio from .extracao_de_metadados import ExtracaoDeMetadados, MetadadosDoArquivo -__all__ = ["ExtracaoDeAudio", "ExtracaoDeMetadados", "MetadadosDoArquivo", "ParteDeAudio"] +__all__ = ["CompactadorDeProxy", "ExtracaoDeAudio", "ExtracaoDeMetadados", + "ExtratorDeQuadrosDoProxy", "MetadadosDoArquivo", "ParteDeAudio", + "RESOLUCAO_PADRAO_DO_PROXY"] diff --git a/code/engine/integracoes/midia/compactador_de_proxy.py b/code/engine/integracoes/midia/compactador_de_proxy.py new file mode 100644 index 0000000..56c43d4 --- /dev/null +++ b/code/engine/integracoes/midia/compactador_de_proxy.py @@ -0,0 +1,170 @@ +"""Transcodifica vídeos de origem para um proxy leve usado na leitura visual e de cenas. + +O gargalo de ler frames direto dos arquivos de origem (ex.: MOV com vários GB) +está em buscar com ``-ss`` em arquivos pesados e não indexados. Este módulo +produz, uma única vez por arquivo e por resolução, uma cópia leve (proxy) em +H.264 com keyframes frequentes e ``+faststart``, sem áudio. Toda a análise +visual e detecção de cena passa a ler desse proxy, que é pequeno, tem seek +rápido e leitura sequencial barata — enquanto o arquivo original fica intocado +para áudio/transcrição. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +import subprocess + +from ...dominio.entidades.modelos import Clipe, IntervaloDeTempo +from ..visual.contratos import ExtratorDeQuadros + +# Resolução padrão do proxy: Full HD (1080) — mantém a qualidade de leitura +# visual (faces/OCR/pose) num arquivo bem mais leve e rápido de processar do +# que o original em 4K. O formato H.264/MP4 +faststart com keyframes frequentes +# é o que torna o seek barato. +RESOLUCAO_PADRAO_DO_PROXY = 1080 + +# Prazo máximo para transcodificar um único arquivo (protetor contra falhas). +LIMITE_DE_TEMPO_DA_TRANSCODIFICACAO = 1800 # 30 min + + +class CompactadorDeProxy: + """Transcodifica arquivos de vídeo para um proxy leve e o reaproveita.""" + + def __init__(self, ffmpeg: str = "ffmpeg", ffprobe: str = "ffprobe", + diretorio_de_cache: str | Path | None = None, + largura_maxima: int = RESOLUCAO_PADRAO_DO_PROXY) -> None: + self.ffmpeg = ffmpeg + self.ffprobe = ffprobe + self.largura_maxima = int(largura_maxima) + self.diretorio_de_cache = ( + Path(diretorio_de_cache) if diretorio_de_cache + else Path.home() / ".cache" / "premiere-mcp" / "proxies" + ) + self.diretorio_de_cache.mkdir(parents=True, exist_ok=True) + + def obter(self, arquivo: str | Path, + largura_maxima: int | None = None) -> Path: + """Devolve o caminho do proxy do arquivo, gerando-o quando necessário. + + O proxy é idempotente: se ele já existir e o arquivo de origem não tiver + mudado desde a última geração, devolve o caminho sem re-transcodificar. + """ + largura = int(largura_maxima or self.largura_maxima) + origem = Path(arquivo).expanduser().resolve(strict=False) + if not origem.is_file(): + raise FileNotFoundError(f"Arquivo de origem não encontrado: {origem}") + + proxy = self._caminho_do_proxy(origem, largura) + if proxy.is_file() and self._proxies_sao_validos(origem, proxy): + return proxy + + self._transcodificar(origem, proxy, largura) + return proxy + + def obter_por_clipe(self, clipe, + largura_maxima: int | None = None) -> Path | None: + """Devolve o proxy do arquivo do clipe, ou None quando não houver arquivo. + + Se o clipe for offline ou não tiver arquivo, devolve None (sem proxy). + """ + if not getattr(clipe, "arquivo", None) or getattr(clipe, "offline", False): + return None + try: + return self.obter(clipe.arquivo, largura_maxima=largura_maxima) + except (FileNotFoundError, RuntimeError): + return None + + # ------------------------------------------------------------------ internos + + def _caminho_do_proxy(self, origem: Path, largura_maxima: int) -> Path: + """Monta o nome estável do proxy a partir do caminho e da largura.""" + resumo = hashlib.sha1(str(origem).encode("utf-8")).hexdigest()[:12] + return self.diretorio_de_cache / f"{origem.stem}-{resumo}-{largura_maxima}p.mp4" + + @staticmethod + def _alteracao_do_arquivo(caminho: Path) -> float: + """Última alteração do arquivo, útil para detectar que a origem mudou.""" + try: + return caminho.stat().st_mtime_ns + except OSError: + return 0.0 + + def _proxies_sao_validos(self, origem: Path, proxy: Path) -> bool: + """O proxy é válido só se nasceu depois do arquivo de origem mudar.""" + return proxy.stat().st_mtime_ns >= self._alteracao_do_arquivo(origem) + + def _transcodificar(self, origem: Path, proxy: Path, largura_maxima: int) -> None: + """Transcodifica o arquivo de origem para o proxy H.264 de leitura visual.""" + comando = [ + self.ffmpeg, "-y", "-hide_banner", "-loglevel", "error", + "-i", str(origem), + "-map", "0:v:0", + "-vf", ( + f"scale=w={largura_maxima}:h={largura_maxima}:" + "force_original_aspect_ratio=decrease" + ), + "-c:v", "libx264", "-preset", "veryfast", "-crf", "28", + "-g", "30", "-keyint_min", "30", "-sc_threshold", "0", + "-pix_fmt", "yuv420p", + "-an", + "-movflags", "+faststart", + str(proxy), + ] + try: + processo = subprocess.run( + comando, capture_output=True, text=True, + timeout=LIMITE_DE_TEMPO_DA_TRANSCODIFICACAO, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"Transcodificação do proxy excedeu o tempo: {origem}" + ) from exc + if processo.returncode != 0 or not proxy.is_file(): + detalhe = (processo.stderr or processo.stdout or "").strip() + raise RuntimeError( + f"Não foi possível gerar o proxy de {origem}: {detalhe}" + ) + + +def _clipe_com_arquivo(clipe: Clipe, caminho: Path) -> Clipe: + """Devolve uma cópia do clipe com o ``arquivo`` substituído pelo caminho dado. + + Mantém a identidade, os intervalos e os metadados; só troca o arquivo lido. + """ + origem = clipe.intervalo_na_origem + return Clipe( + identificador=clipe.identificador, + nome=clipe.nome, + intervalo_na_timeline=clipe.intervalo_na_timeline, + intervalo_na_origem=IntervaloDeTempo(origem.inicio, origem.fim) if origem else None, + arquivo=str(caminho), + identificador_da_faixa=clipe.identificador_da_faixa, + offline=clipe.offline, + metadados=clipe.metadados, + ) + + +class ExtratorDeQuadrosDoProxy: + """Extrai frames de um clipe a partir do seu proxy, sem duplicar lógica. + + Delega todo o trabalho ao ``ExtratorDeQuadrosFFmpeg`` informado, apontando-o + para o proxy. Os timestamps continuam relativos ao tempo do arquivo original, + pois o proxy preserva a mesma cronologia. + """ + + def __init__(self, extrator: ExtratorDeQuadros, compactador: CompactadorDeProxy, + largura_maxima: int | None = None) -> None: + self.extrator = extrator + self.compactador = compactador + self.largura_maxima = largura_maxima + + def extrair(self, clipe: Clipe) -> list: + """Extrai frames usando o proxy; usa o arquivo original quando não há proxy.""" + proxy = ( + self.compactador.obter_por_clipe(clipe) + if self.largura_maxima is None + else self.compactador.obter_por_clipe(clipe, largura_maxima=self.largura_maxima) + ) + alvo = clipe if proxy is None else _clipe_com_arquivo(clipe, proxy) + return self.extrator.extrair(alvo) \ No newline at end of file diff --git a/code/engine/integracoes/visual/apple_vision.py b/code/engine/integracoes/visual/apple_vision.py index d288afd..242ee4f 100644 --- a/code/engine/integracoes/visual/apple_vision.py +++ b/code/engine/integracoes/visual/apple_vision.py @@ -143,17 +143,25 @@ class AnalisadorAppleVisionNativo(AnalisadorDeFrame): def __init__(self, recursos: tuple[str, ...] = RECURSOS_PADRAO, interpretar_com_apple_intelligence: bool = True, executor: ExecutorDoRunnerApple | None = None, - timeout_em_segundos: float = 90.0) -> None: + timeout_em_segundos: float = 90.0, + maximo_de_frames_interpretados: int | None = None) -> None: self.recursos = recursos self.interpretar_com_apple_intelligence = interpretar_com_apple_intelligence self.executor = executor or ExecutorDoRunnerApple() self.timeout_em_segundos = timeout_em_segundos + # Limita quantos frames por clipe fazem a interpretação editorial (Apple + # Intelligence / Foundation Models), que é a parte mais cara. Os demais + # frames continuam com a análise visual completa. None = interpreta todos. + self.maximo_de_frames_interpretados = maximo_de_frames_interpretados - def analisar(self, quadro: QuadroDeVideo) -> list[EvidenciaVisual]: + def analisar(self, quadro: QuadroDeVideo, interpretar: bool | None = None) -> list[EvidenciaVisual]: if quadro.caminho is None: raise ValueError("Apple Vision requer que o frame tenha caminho persistido em disco.") + if self._indices_para_interpretar and quadro.indice not in self._indices_para_interpretar: + interpretar = False + deve_interpretar = interpretar if interpretar is not None else self.interpretar_com_apple_intelligence dados = self.executor.executar({"frame": str(quadro.caminho), "recursos": list(self.recursos), - "resumir": self.interpretar_com_apple_intelligence}, + "resumir": deve_interpretar}, self.timeout_em_segundos) evidencias = [self._converter(item, quadro) for item in dados.get("evidencias", [])] avisos = dados.get("avisos", []) @@ -162,6 +170,27 @@ class AnalisadorAppleVisionNativo(AnalisadorDeFrame): {"avisos": avisos, "recursos_solicitados": list(self.recursos)}, provider=self.nome)) return evidencias + def preparar_interpretacao_por_clipe(self, quantidade_de_frames: int) -> None: + """Marca os índices de frames que receberão a interpretação editorial. + + Distribui a interpretação ao longo do clipe (início, pontos médios e fim) + em vez de aplicar a todos os frames, mantendo a cobertura de cena com + custo muito menor. Quando não há limite (None), interpreta todos. + """ + if self.maximo_de_frames_interpretados is None or quantidade_de_frames <= 0: + self._indices_para_interpretar = set(range(quantidade_de_frames)) + return + quantidade = min(quantidade_de_frames, max(1, self.maximo_de_frames_interpretados)) + indices = set() + if quantidade_de_frames == 1: + indices.add(0) + else: + passo = max(1, quantidade_de_frames // quantidade) + indices = set(range(0, quantidade_de_frames, passo)) | {quantidade_de_frames - 1} + self._indices_para_interpretar = {i for i in indices if i < quantidade_de_frames} + + _indices_para_interpretar: set[int] = set() + def _converter(self, item: dict[str, Any], quadro: QuadroDeVideo) -> EvidenciaVisual: return EvidenciaVisual(item["tipo"], quadro.timestamp, quadro.timestamp, item["valor"], confianca=item.get("confianca"), provider=self.nome, diff --git a/code/engine/scanner/configuracao_visual.py b/code/engine/scanner/configuracao_visual.py index 05adcb2..74c114e 100644 --- a/code/engine/scanner/configuracao_visual.py +++ b/code/engine/scanner/configuracao_visual.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Any +from ..integracoes.midia import RESOLUCAO_PADRAO_DO_PROXY + RECURSOS_VISION = frozenset(( "faces", "qualidade_facial", "pessoas", "pose", "maos", "ocr", "categorias", @@ -21,6 +23,13 @@ SENSIBILIDADES_VISUAIS: dict[str, float] = { } +def _inteiro_ou_nulo(valor: Any) -> int | None: + """Converte um valor de perfil em int, preservando None.""" + if valor is None or valor == "": + return None + return int(valor) + + @dataclass(frozen=True) class ConfiguracaoVisualDoScanner: """Interface curta para uma execução de leitura visual da timeline.""" @@ -38,6 +47,13 @@ class ConfiguracaoVisualDoScanner: usar_analise_visual: bool = False detectar_cortes: bool = True sensibilidade: str = "media" + # Leitura via proxy compactado. Para arquivos de origem pesados (MOV de + # vários GB), ler os frames de uma cópia leve é muito mais rápido. + usar_proxy: bool = True + resolucao_do_proxy: int = RESOLUCAO_PADRAO_DO_PROXY + # Limita quantos frames por clipe recebem a interpretação editorial da Apple + # Intelligence (Foundation Models) — a etapa mais cara. None interpreta todos. + maximo_de_frames_interpretados: int | None = None def __post_init__(self) -> None: if not 0.04 <= self.intervalo_em_segundos <= 60: @@ -75,6 +91,9 @@ class ConfiguracaoVisualDoScanner: usar_analise_visual=bool(dados.get("usar_analise_visual", False)), detectar_cortes=bool(dados.get("detectar_cortes", True)), sensibilidade=str(dados.get("sensibilidade", "media")), + usar_proxy=bool(dados.get("usar_proxy", True)), + resolucao_do_proxy=int(dados.get("resolucao_do_proxy", RESOLUCAO_PADRAO_DO_PROXY)), + maximo_de_frames_interpretados=_inteiro_ou_nulo(dados.get("maximo_de_frames_interpretados", None)), ) def para_dict(self) -> dict[str, Any]: @@ -92,4 +111,7 @@ class ConfiguracaoVisualDoScanner: "usar_analise_visual": self.usar_analise_visual, "detectar_cortes": self.detectar_cortes, "sensibilidade": self.sensibilidade, + "usar_proxy": self.usar_proxy, + "resolucao_do_proxy": self.resolucao_do_proxy, + "maximo_de_frames_interpretados": self.maximo_de_frames_interpretados, } diff --git a/code/engine/scanner/visual.py b/code/engine/scanner/visual.py index 2dbd8b1..ea9a78a 100644 --- a/code/engine/scanner/visual.py +++ b/code/engine/scanner/visual.py @@ -40,6 +40,7 @@ class DetectorDeCenas: def detectar(self, clipe: Clipe) -> ResultadoVisualDoClipe: quadros = self.extrator.extrair(clipe) + self._preparar_analisadores(quadros) total = len(quadros) * len(self.analisadores_de_frame) + len(self.analisadores_de_sequencia) progresso = [0] evidencias = self._analisar_quadros(quadros, progresso, total) + self._analisar_sequencia(quadros, progresso, total) @@ -51,6 +52,19 @@ class DetectorDeCenas: ) for cena in cenas] return ResultadoVisualDoClipe(clipe.identificador, evidencias, cenas, cenas_visuais) + def _preparar_analisadores(self, quadros: list[QuadroDeVideo]) -> None: + """Prepara analisadores que precisam saber a quantidade de frames do clipe. + + Caso especial do `AnalisadorAppleVisionNativo`: ele distribui a + interpretação editorial (Apple Intelligence / Foundation Models) em um + subconjunto dos frames para manter a cobertura de cena com custo menor. + """ + quantidade_de_quadros = len(quadros) + for analisador in self.analisadores_de_frame: + preparar = getattr(analisador, "preparar_interpretacao_por_clipe", None) + if callable(preparar): + preparar(quantidade_de_quadros) + def _analisar_quadros(self, quadros: list[QuadroDeVideo], progresso: list[int] | None = None, total: int = 0) -> list[EvidenciaVisual]: if not self.analisadores_de_frame or not quadros: @@ -205,18 +219,35 @@ def criar_detector_apple_vision( from ..integracoes.visual import (AnalisadorAppleVisionNativo, AnalisadorSequenciaAppleVisionNativo, ExtratorDeQuadrosFFmpeg) + from ..integracoes.midia import CompactadorDeProxy, ExtratorDeQuadrosDoProxy perfil = configuracao or ConfiguracaoVisualDoScanner(intervalo_em_segundos=intervalo_em_segundos) + # Quantidade fixa de trabalhadores para extração e análise de frames. + # Em volumes externos (ex.: /Volumes/Merongo), mais threads do que isso + # satura a E/S e trava os processos de mídia a ~0% de CPU. + QUANTIDADE_DE_TRABALHADORES = 4 + + extrator = ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos, + diretorio_de_cache=diretorio_de_cache or ".frames", + resolucao_maxima=perfil.resolucao_maxima, + trabalhadores_em_paralelo=QUANTIDADE_DE_TRABALHADORES) + # Leitura via proxy compactado: para arquivos pesados, evita buscar frame + # por frame no arquivo original (um -ss por frame num MOV de vários GB). + if perfil.usar_proxy: + extrator = ExtratorDeQuadrosDoProxy( + extrator, CompactadorDeProxy(), largura_maxima=perfil.resolucao_do_proxy + ) + return DetectorDeCenas( - ExtratorDeQuadrosFFmpeg(intervalo_em_segundos=perfil.intervalo_em_segundos, - diretorio_de_cache=diretorio_de_cache or ".frames", - resolucao_maxima=perfil.resolucao_maxima), + extrator, analisadores_de_frame=[AnalisadorAppleVisionNativo( recursos=tuple(sorted(perfil.recursos_vision)), interpretar_com_apple_intelligence=perfil.interpretar_cena, + maximo_de_frames_interpretados=perfil.maximo_de_frames_interpretados, )], analisadores_de_sequencia=[AnalisadorSequenciaAppleVisionNativo()] if perfil.analisar_continuidade else [], detectores_de_intervalo=_detectores_de_cena_do_perfil(perfil), + trabalhadores_em_paralelo=QUANTIDADE_DE_TRABALHADORES, )