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>
241 lines
8.7 KiB
Python
241 lines
8.7 KiB
Python
"""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
|
|
|
|
# ========================================================================
|