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:
co-authored by
Claude Opus 5
parent
1bebee4359
commit
4f5cf94443
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user