feat(v3): PR 7 — ResilientAssembler with GPU encoder detection + per-clip fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -413,3 +413,218 @@ def assemble(
|
||||
if tmp_cleanup and work_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
|
||||
# ─── GPU Encoder Detection ────────────────────────────────────
|
||||
|
||||
def _detect_gpu_encoder(ffmpeg: str = 'ffmpeg') -> str:
|
||||
"""
|
||||
Detect available GPU encoder in priority order:
|
||||
nvenc (NVIDIA) > amf (AMD) > qsv (Intel) > libx264 (CPU)
|
||||
|
||||
Returns: encoder name string
|
||||
"""
|
||||
encoders_to_try = [
|
||||
('h264_nvenc', ['-hwaccel', 'cuda']), # NVIDIA
|
||||
('h264_amf', []), # AMD
|
||||
('h264_qsv', ['-hwaccel', 'qsv']), # Intel
|
||||
]
|
||||
|
||||
import tempfile, subprocess
|
||||
|
||||
for encoder, hwaccel_args in encoders_to_try:
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as f:
|
||||
test_out = f.name
|
||||
cmd = (
|
||||
[ffmpeg, '-y', '-loglevel', 'error']
|
||||
+ hwaccel_args
|
||||
+ ['-f', 'lavfi', '-i', 'color=black:s=16x16:r=1',
|
||||
'-t', '0.1',
|
||||
'-c:v', encoder,
|
||||
test_out]
|
||||
)
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||||
Path(test_out).unlink(missing_ok=True)
|
||||
if result.returncode == 0:
|
||||
logger.info(f'[GPU] 인코더 감지: {encoder}')
|
||||
return encoder
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info('[GPU] GPU 인코더 없음 — libx264 사용')
|
||||
return 'libx264'
|
||||
|
||||
|
||||
# ─── Resilient Assembler ─────────────────────────────────────
|
||||
|
||||
class ResilientAssembler:
|
||||
"""
|
||||
Resilient video assembler with:
|
||||
1. Per-clip encoding (fail one → fallback that clip only)
|
||||
2. Timeout per FFmpeg process (5 minutes)
|
||||
3. GPU encoder auto-detection (nvenc/amf/qsv/cpu)
|
||||
4. Progress reporting (logs every clip)
|
||||
|
||||
Use assemble_resilient() instead of the module-level assemble() for better fault tolerance.
|
||||
"""
|
||||
|
||||
CLIP_TIMEOUT = 300 # 5 minutes per clip
|
||||
FINAL_TIMEOUT = 600 # 10 minutes for final assembly
|
||||
|
||||
def __init__(self, cfg: dict = None):
|
||||
"""
|
||||
cfg: shorts_config.json dict (loaded automatically if None)
|
||||
"""
|
||||
self._cfg = cfg or _load_config()
|
||||
self._ffmpeg = _get_ffmpeg()
|
||||
self._encoder = None # Lazy detection
|
||||
|
||||
def _get_encoder(self) -> str:
|
||||
"""Detect and cache GPU encoder."""
|
||||
if self._encoder is None:
|
||||
self._encoder = _detect_gpu_encoder(self._ffmpeg)
|
||||
return self._encoder
|
||||
|
||||
def _encode_clip(self, clip_path: Path, index: int, work_dir: Path) -> Path:
|
||||
"""
|
||||
Encode a single clip to standardized format.
|
||||
|
||||
Returns: path to encoded clip
|
||||
Raises: RuntimeError on failure (triggers fallback)
|
||||
"""
|
||||
out = work_dir / f'encoded_{index:02d}.mp4'
|
||||
encoder = self._get_encoder()
|
||||
|
||||
cmd = [
|
||||
self._ffmpeg, '-y',
|
||||
'-i', str(clip_path),
|
||||
'-c:v', encoder,
|
||||
'-crf', '20' if encoder == 'libx264' else '20',
|
||||
'-preset', 'fast' if encoder == 'libx264' else 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', '-r', '30',
|
||||
str(out),
|
||||
]
|
||||
|
||||
# Adjust args for GPU encoders (they use different quality flags)
|
||||
if encoder != 'libx264':
|
||||
cmd = [
|
||||
self._ffmpeg, '-y',
|
||||
'-i', str(clip_path),
|
||||
'-c:v', encoder,
|
||||
'-b:v', '2M', # Bitrate for GPU encoders
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', '-r', '30',
|
||||
str(out),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, timeout=self.CLIP_TIMEOUT
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f'FFmpeg error: {result.stderr.decode(errors="ignore")[-200:]}')
|
||||
logger.info(f'[조립] 클립 {index} 인코딩 완료 ({encoder})')
|
||||
return out
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(f'클립 {index} 인코딩 타임아웃 ({self.CLIP_TIMEOUT}초)')
|
||||
|
||||
def _fallback_clip(self, clip_path: Path, index: int, work_dir: Path) -> Path:
|
||||
"""
|
||||
Fallback clip encoding using libx264 (CPU, always works).
|
||||
"""
|
||||
logger.warning(f'[조립] 클립 {index} 폴백 인코딩 (libx264)')
|
||||
out = work_dir / f'fallback_{index:02d}.mp4'
|
||||
|
||||
cmd = [
|
||||
self._ffmpeg, '-y',
|
||||
'-i', str(clip_path),
|
||||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-an', '-r', '30',
|
||||
str(out),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=self.CLIP_TIMEOUT)
|
||||
if result.returncode != 0:
|
||||
logger.error(f'[조립] 폴백도 실패 (클립 {index}): {result.stderr.decode(errors="ignore")[-100:]}')
|
||||
return clip_path # Return original as last resort
|
||||
return out
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f'[조립] 폴백 타임아웃 (클립 {index})')
|
||||
return clip_path
|
||||
|
||||
def assemble_resilient(
|
||||
self,
|
||||
clips: list[Path],
|
||||
tts_wav: Path,
|
||||
ass_path: Optional[Path],
|
||||
output_dir: Path,
|
||||
timestamp: str,
|
||||
work_dir: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Resilient version of assemble() with per-clip fallback.
|
||||
|
||||
Key differences from assemble():
|
||||
1. Each clip is encoded individually — failure → fallback that clip only
|
||||
2. GPU encoder used when available
|
||||
3. Per-process timeout (5 min per clip)
|
||||
4. Progress logged per clip
|
||||
|
||||
Args:
|
||||
Same as assemble()
|
||||
|
||||
Returns: Path to rendered MP4
|
||||
Raises: RuntimeError only if ALL clips fail or final assembly fails
|
||||
"""
|
||||
import contextlib, shutil
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tmp_cleanup = work_dir is None
|
||||
if work_dir is None:
|
||||
work_dir = output_dir / f'_resilient_{timestamp}'
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Step 1: Encode each clip (with per-clip fallback)
|
||||
encoded = []
|
||||
failed_count = 0
|
||||
for i, clip in enumerate(clips):
|
||||
logger.info(f'[조립] 클립 {i+1}/{len(clips)} 처리 중...')
|
||||
try:
|
||||
enc = self._encode_clip(clip, i, work_dir)
|
||||
encoded.append(enc)
|
||||
except Exception as e:
|
||||
logger.warning(f'[조립] 클립 {i} 인코딩 실패: {e} — 폴백 사용')
|
||||
failed_count += 1
|
||||
fb = self._fallback_clip(clip, i, work_dir)
|
||||
encoded.append(fb)
|
||||
|
||||
if not encoded:
|
||||
raise RuntimeError('[조립] 인코딩된 클립 없음 — 조립 불가')
|
||||
|
||||
if failed_count > 0:
|
||||
logger.warning(f'[조립] {failed_count}/{len(clips)} 클립이 폴백으로 인코딩됨')
|
||||
|
||||
# Step 2: Use the existing assemble() for the rest (concat + audio + subtitles)
|
||||
# This reuses all the battle-tested logic from the original assembler
|
||||
result_path = assemble(
|
||||
clips=encoded,
|
||||
tts_wav=tts_wav,
|
||||
ass_path=ass_path,
|
||||
output_dir=output_dir,
|
||||
timestamp=timestamp,
|
||||
cfg=self._cfg,
|
||||
work_dir=work_dir / 'assemble',
|
||||
)
|
||||
|
||||
logger.info(f'[조립] 탄력적 조립 완료: {result_path.name}')
|
||||
return result_path
|
||||
|
||||
finally:
|
||||
if tmp_cleanup and work_dir.exists():
|
||||
shutil.rmtree(work_dir, ignore_errors=True)
|
||||
|
||||
Reference in New Issue
Block a user