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
+240
View File
@@ -0,0 +1,240 @@
"""Corte rápido: flash frames, rapid trim, preencher buracos.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
from typing import Any, Dict, List, Optional
class RapidMixin:
"""Corte rápido: flash frames, rapid trim, preencher buracos."""
# SPEED CUTTING OPERATIONS (v0.3.0)
# ========================================================================
def fix_flash_frames(
self,
mode: str = 'auto',
threshold_frames: int = 6,
critical_threshold_frames: int = 2
) -> List[Dict[str, Any]]:
"""
Automatically fix flash frames (ultra-short clips).
Args:
mode: How to fix flash frames:
- 'extend_previous': Extend the previous clip to cover the flash frame
- 'extend_next': Extend the next clip backward to cover the flash frame
- 'delete': Remove the flash frame entirely (ripple)
- 'auto': Use smart logic (extend prev for critical, delete for warning)
threshold_frames: Frames below this are considered flash frames
critical_threshold_frames: Frames below this are critical (default: 2)
Returns:
List of fixed flash frames with details
"""
spine = self._get_spine()
fixed = []
# Collect flash frames first (can't modify while iterating)
flash_frames = []
for i, clip in self._iter_spine_clips():
duration = self._parse_time(clip.get('duration', '0s'))
duration_frames = duration.to_frames(self.fps)
if duration_frames < threshold_frames:
is_critical = duration_frames < critical_threshold_frames
flash_frames.append({
'index': i,
'clip': clip,
'clip_id': clip.get('name') or clip.get('id') or f"clip_{i}",
'duration_frames': duration_frames,
'is_critical': is_critical
})
# Process in reverse order to maintain indices
for ff in reversed(flash_frames):
clip = ff['clip']
_, _, clip_offset = self._get_clip_times(clip)
# Determine actual mode
actual_mode = mode
if mode == 'auto':
# Critical: try to extend previous, otherwise delete
# Warning: delete
actual_mode = 'extend_previous' if ff['is_critical'] else 'delete'
result = {
'clip_name': ff['clip_id'],
'duration_frames': ff['duration_frames'],
'was_critical': ff['is_critical'],
'action': actual_mode,
'timecode': clip_offset.to_timecode(self.fps)
}
direction = {'extend_previous': 'prev', 'extend_next': 'next'}.get(actual_mode)
if direction:
neighbor = self._absorb_into_neighbor(spine, clip, direction)
if neighbor is not None:
self._recalculate_offsets(spine)
result['extended_clip'] = neighbor.get('name', direction.title())
else:
spine.remove(clip)
self._recalculate_offsets(spine)
else: # delete
spine.remove(clip)
self._recalculate_offsets(spine)
fixed.append(result)
# Rebuild clip index
self._build_clip_index()
return fixed
def rapid_trim(
self,
max_duration: Optional[str] = None,
min_duration: Optional[str] = None,
keywords: Optional[List[str]] = None,
trim_from: str = 'end'
) -> List[Dict[str, Any]]:
"""
Batch trim clips to enforce duration limits.
Args:
max_duration: Maximum clip duration (e.g., '2s', '00:00:02:00')
min_duration: Minimum clip duration (clips shorter are extended/left alone)
keywords: Only trim clips with these keywords (None = all clips)
trim_from: Where to trim - 'start', 'end', or 'center'
Returns:
List of trimmed clips with before/after durations
"""
trimmed = []
max_dur = self._parse_time(max_duration) if max_duration else None
min_dur = self._parse_time(min_duration) if min_duration else None
for _i, clip in self._iter_spine_clips():
clip_name = clip.get('name') or clip.get('id') or 'Unknown'
# Check keyword filter
if keywords:
clip_keywords = set()
for kw_elem in clip.findall('keyword'):
clip_keywords.add(kw_elem.get('value', ''))
if not clip_keywords.intersection(set(keywords)):
continue
current_start, current_duration, _ = self._get_clip_times(clip)
original_duration = current_duration.to_seconds()
# Skip clips shorter than min_duration (leave them alone)
if min_dur and current_duration < min_dur:
continue
# Check max duration
if max_dur and current_duration > max_dur:
excess = current_duration - max_dur
if trim_from == 'end':
# Keep start, reduce duration
clip.set('duration', max_dur.to_fcpxml())
elif trim_from == 'start':
# Increase start, reduce duration
new_start = current_start + excess
clip.set('start', new_start.to_fcpxml())
clip.set('duration', max_dur.to_fcpxml())
elif trim_from == 'center':
# Trim equal amounts from both ends
half_excess = excess * 0.5
new_start = current_start + half_excess
clip.set('start', new_start.to_fcpxml())
clip.set('duration', max_dur.to_fcpxml())
trimmed.append({
'clip_name': clip_name,
'original_duration': original_duration,
'new_duration': max_dur.to_seconds(),
'trim_from': trim_from,
'action': 'trimmed'
})
# Recalculate offsets
self._recalculate_offsets(self._get_spine())
return trimmed
def fill_gaps(
self,
mode: str = 'extend_previous',
max_gap: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
Fill gaps in the timeline.
Args:
mode: How to fill gaps:
- 'extend_previous': Extend previous clip to fill gap
- 'extend_next': Extend next clip backward to fill gap
- 'delete': Remove gap elements and ripple
max_gap: Only fill gaps smaller than this (None = all gaps)
Returns:
List of filled gaps with details
"""
spine = self._get_spine()
filled = []
max_gap_time = self._parse_time(max_gap) if max_gap else None
# Find all gaps
gaps_to_process = []
for i, child in enumerate(list(spine)):
if child.tag == 'gap':
gap_duration = self._parse_time(child.get('duration', '0s'))
gap_offset = self._parse_time(child.get('offset', '0s'))
# Check max_gap filter
if max_gap_time and gap_duration > max_gap_time:
continue
gaps_to_process.append({
'element': child,
'index': i,
'duration': gap_duration,
'offset': gap_offset
})
# Process in reverse to maintain indices
for gap_info in reversed(gaps_to_process):
gap = gap_info['element']
gap_duration = gap_info['duration']
gap_offset = gap_info['offset']
result = {
'timecode': gap_offset.to_timecode(self.fps),
'duration_frames': gap_duration.to_frames(self.fps),
'duration_seconds': gap_duration.to_seconds(),
'action': mode
}
direction = {'extend_previous': 'prev', 'extend_next': 'next'}.get(mode)
if direction:
neighbor = self._absorb_into_neighbor(spine, gap, direction)
if neighbor is not None:
result['extended_clip'] = neighbor.get('name', direction.title())
filled.append(result)
else: # delete
spine.remove(gap)
filled.append(result)
# Recalculate offsets
self._recalculate_offsets(spine)
return filled
# ========================================================================