refactor: writer.py vira pacote, um módulo por assunto

O writer tinha 4.199 linhas, das quais 3.300 numa única classe com dezoito
assuntos dentro. Achar o trecho de zoom exigia rolar por marcadores,
velocidade e legendas.

Agora é o pacote fcpxml/writer/, com um arquivo por assunto e o
FCPXMLModifier montado por composição de mixins. Mixins, e não objetos
separados, porque todas essas operações mexem no mesmo documento e nos
mesmos índices — separá-las em objetos independentes transformaria toda
chamada interna em travessia de fronteira sem nada em troca. A divisão que
importa aqui é de leitura, não de estado.

Nenhuma mudança de comportamento e nenhuma alteração nos ~50 pontos que
importam do writer: o __init__ re-exporta tudo, inclusive os nomes com
underscore que a suíte já usava.

    core      723   carga, índices, navegação na spine, save
    titles    600   títulos e legendas dinâmicas
    cut       333   dividir, cortar faixas, apagar
    speed     297   velocidade e zoom
    (+ 20 módulos menores)

Único ajuste de chamada: quatro testes faziam patch em
fcpxml.writer.subprocess, que agora mora em writer.document (ver
Engine/docs/05_EXPERIENCIAS.md #23).

Lint zerado, 1441 testes passando.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
João Henrique
2026-08-19 21:38:49 -04:00
co-authored by Claude Opus 5
parent 1bebee4359
commit 4f5cf94443
28 changed files with 4719 additions and 4203 deletions
+185
View File
@@ -0,0 +1,185 @@
"""Detectar e remover silêncio.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
from typing import Any, Dict, List, Optional
from ..models import (
MarkerType,
TimeValue,
)
from .helpers import CLIP_TAGS, build_marker_element
class SilenceMixin:
"""Detectar e remover silêncio."""
# SILENCE DETECTION OPERATIONS (v0.5.0)
# ========================================================================
def detect_silence_candidates(
self,
min_gap_seconds: float = 0.5,
patterns: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Detect potential silence regions using timeline heuristics.
Checks for:
1. Gap elements in spine (high confidence)
2. Ultra-short clips < 0.5s (medium confidence)
3. Clips matching name patterns like "silence", "room tone" (high)
4. Duration anomalies > 2 std dev from mean (low-medium)
Args:
min_gap_seconds: Minimum gap duration to flag
patterns: Name patterns to match (default: gap, silence, room tone)
Returns:
List of silence candidate dicts
"""
if patterns is None:
patterns = ['gap', 'silence', 'room tone', 'dead air', 'blank']
spine = self._get_spine()
candidates = []
durations = []
clip_index = 0
# First pass: collect durations for anomaly detection
for child in spine:
if child.tag in CLIP_TAGS:
dur = self._parse_time(child.get('duration', '0s'))
durations.append(dur.to_seconds())
# Calculate stats for anomaly detection
mean_dur = sum(durations) / len(durations) if durations else 0
variance = (sum((d - mean_dur) ** 2 for d in durations) / len(durations)
if len(durations) > 1 else 0)
std_dev = variance ** 0.5
# Second pass: detect candidates
for child in spine:
tag = child.tag
offset = child.get('offset', '0s')
dur = self._parse_time(child.get('duration', '0s'))
dur_secs = dur.to_seconds()
tc = TimeValue.from_timecode(offset, self.fps).to_timecode(self.fps)
if tag == 'gap' and dur_secs >= min_gap_seconds:
candidates.append({
'start_timecode': tc,
'duration_seconds': dur_secs,
'reason': 'gap',
'confidence': 0.9,
'clip_name': None,
'clip_index': None,
})
elif tag in CLIP_TAGS:
name = child.get('name', '').lower()
# Name pattern match
for pat in patterns:
if pat.lower() in name:
candidates.append({
'start_timecode': tc,
'duration_seconds': dur_secs,
'reason': 'name_match',
'confidence': 0.85,
'clip_name': child.get('name', ''),
'clip_index': clip_index,
})
break
# Ultra-short clip
if dur_secs < 0.5:
candidates.append({
'start_timecode': tc,
'duration_seconds': dur_secs,
'reason': 'ultra_short',
'confidence': 0.6,
'clip_name': child.get('name', ''),
'clip_index': clip_index,
})
# Duration anomaly (> 2 std dev longer than mean)
if std_dev > 0 and dur_secs > mean_dur + 2 * std_dev:
candidates.append({
'start_timecode': tc,
'duration_seconds': dur_secs,
'reason': 'duration_anomaly',
'confidence': 0.4,
'clip_name': child.get('name', ''),
'clip_index': clip_index,
})
clip_index += 1
return candidates
def remove_silence_candidates(
self,
mode: str = "mark",
min_gap_seconds: float = 0.5,
min_confidence: float = 0.7,
patterns: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Remove or mark detected silence candidates.
Args:
mode: "delete" removes clips/gaps, "mark" adds red markers,
"shorten" trims to minimum
min_gap_seconds: Minimum gap to consider
min_confidence: Only act on candidates above this threshold
patterns: Name patterns to match
Returns:
List of actions taken
"""
candidates = self.detect_silence_candidates(min_gap_seconds, patterns)
candidates = [c for c in candidates if c['confidence'] >= min_confidence]
spine = self._get_spine()
actions = []
if mode == "mark":
for c in candidates:
child = self._find_spine_element_at_timecode(
spine, c['start_timecode'], require_clip=True
)
if child is not None:
build_marker_element(
parent=child,
marker_type=MarkerType.STANDARD,
start=child.get('start', '0s'),
duration=f"1/{int(self.fps)}s",
name=f"SILENCE: {c['reason']}",
)
actions.append({
'action': 'marked',
'clip_name': c.get('clip_name', 'gap'),
'reason': c['reason'],
})
elif mode == "delete":
elements_to_remove = []
for c in candidates:
child = self._find_spine_element_at_timecode(
spine, c['start_timecode']
)
if child is not None:
elements_to_remove.append(child)
actions.append({
'action': 'deleted',
'clip_name': c.get('clip_name', 'gap'),
'reason': c['reason'],
})
for elem in elements_to_remove:
spine.remove(elem)
if elements_to_remove:
self._recalculate_offsets(spine)
return actions