NAS 저성능 CPU(J4025) ffmpeg 5분 타임아웃 → Windows PC RTX 5070 Ti NVENC로 오프로드. 같은 music_ai 서버에 /encode_video endpoint 추가, NAS는 다운 시 즉시 실패 (로컬 폴백 X). LAN 무인증. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
487 lines
18 KiB
Markdown
487 lines
18 KiB
Markdown
# GPU 영상 인코딩 오프로드 — 설계
|
||
|
||
> 작성일: 2026-05-09
|
||
> 관련: `2026-05-07-music-youtube-pipeline-design.md` (Task 4 대체)
|
||
|
||
---
|
||
|
||
## 1. 배경
|
||
|
||
NAS Synology Celeron J4025(2 cores @ 2.0GHz, GPU 없음)에서 1920×1080 visualizer 영상 인코딩이 너무 느림. 176초 트랙 인코딩에 5분 초과 → ffmpeg `subprocess.TimeoutExpired`. `-preset ultrafast`로 가속해도 한계 있고 화질 저하.
|
||
|
||
대안: 사용자 Windows PC(RTX 5070 Ti, 16GB VRAM)에서 NVIDIA NVENC 하드웨어 인코딩으로 처리. 같은 영상이 **10–20초**에 완료(20×+ 빠름).
|
||
|
||
이미 `music_ai` 서버(Windows, port 8765)가 MusicGen용으로 동작 중이므로 **같은 서버에 영상 인코딩 endpoint를 추가**하는 것이 가장 자연스럽다.
|
||
|
||
---
|
||
|
||
## 2. 비목표
|
||
|
||
- 다중 GPU/멀티 머신 — 단일 Windows PC만 지원
|
||
- NAS 로컬 ffmpeg 폴백 — 사용자 결정으로 제외 (Windows 서버 다운 시 명확한 실패 선호)
|
||
- 영상 길이 제한 — 일반 트랙 길이(1–10분) 가정
|
||
- 인증 — LAN 전용, 무인증
|
||
|
||
---
|
||
|
||
## 3. 아키텍처
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────────┐
|
||
│ NAS (Synology) │
|
||
│ │
|
||
│ music-lab container │
|
||
│ pipeline/video.py │
|
||
│ ↓ HTTP POST {paths, resolution} │
|
||
│ ↓ 192.168.45.59:8765/encode_video │
|
||
│ │
|
||
│ /volume1/docker/webpage/data/ │
|
||
│ videos/{id}/cover.jpg ← input │
|
||
│ videos/{id}/video.mp4 ← output (Windows가 직접 씀) │
|
||
│ {audio}.mp3 ← input │
|
||
└────────────────────────────────────────────────────────────┘
|
||
↓ HTTP ↑ SMB read/write
|
||
↓ ↑ (Z:\ 마운트)
|
||
┌────────────────────────────────────────────────────────────┐
|
||
│ Windows PC (192.168.45.59) │
|
||
│ │
|
||
│ music_ai server.py (port 8765) │
|
||
│ • POST /generate (기존, MusicGen) │
|
||
│ • POST /encode_video (신규) │
|
||
│ ↓ 경로 변환: /volume1/... → Z:\... │
|
||
│ ↓ ffmpeg.exe -hwaccel cuda -c:v h264_nvenc ... │
|
||
│ ↓ 입력/출력 모두 Z:\ 직접 (SMB) │
|
||
│ ↓ 응답: {ok, duration_ms, output_path} │
|
||
│ │
|
||
│ Z:\docker\webpage\data\ (NAS SMB mount, 기존) │
|
||
│ videos\{id}\cover.jpg │
|
||
│ videos\{id}\video.mp4 │
|
||
│ {audio}.mp3 │
|
||
└────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**핵심 원칙:** 파일은 SMB로 직접 읽고 쓰기 — HTTP는 메타데이터(경로 + 옵션)만 전달.
|
||
|
||
---
|
||
|
||
## 4. Windows `music_ai` 서버 — `/encode_video` endpoint
|
||
|
||
### 4-1. Request
|
||
|
||
```http
|
||
POST /encode_video HTTP/1.1
|
||
Host: 192.168.45.59:8765
|
||
Content-Type: application/json
|
||
|
||
```
|
||
|
||
| 필드 | 타입 | 필수 | 설명 |
|
||
|------|------|------|------|
|
||
| `cover_path_nas` | string | ✓ | 배경 이미지 NAS 절대경로 |
|
||
| `audio_path_nas` | string | ✓ | 오디오 파일 NAS 절대경로 |
|
||
| `output_path_nas` | string | ✓ | 출력 mp4 NAS 절대경로 |
|
||
| `resolution` | string | ✓ | `WIDTHxHEIGHT` (예: `1920x1080`) |
|
||
| `duration_sec` | int | | 트랙 길이 — 진행 추적용 (옵션) |
|
||
| `style` | string | | 현재 `visualizer`만 (확장용) |
|
||
|
||
### 4-2. Response
|
||
|
||
**성공 (200):**
|
||
```json
|
||
{
|
||
"ok": true,
|
||
"duration_ms": 12340,
|
||
"output_path_nas": "/volume1/docker/webpage/data/videos/3/video.mp4",
|
||
"output_bytes": 28470000,
|
||
"encoder": "h264_nvenc",
|
||
"preset": "p4"
|
||
}
|
||
```
|
||
|
||
**실패 (4xx/5xx):**
|
||
```json
|
||
{
|
||
"ok": false,
|
||
"error": "ffmpeg returncode=1: ...",
|
||
"stage": "ffmpeg" // path_translate | input_validation | ffmpeg | output_check
|
||
}
|
||
```
|
||
|
||
### 4-3. 경로 변환
|
||
|
||
Windows 서버는 `nas_path → windows_path` 변환을 환경변수 기반으로 수행:
|
||
|
||
```python
|
||
# .env (Windows music_ai)
|
||
NAS_VOLUME_PREFIX=/volume1/
|
||
WINDOWS_DRIVE_ROOT=Z:\
|
||
```
|
||
|
||
변환 로직:
|
||
```python
|
||
def translate_path(nas_path: str) -> str:
|
||
# /volume1/docker/webpage/data/videos/3/cover.jpg
|
||
# → Z:\docker\webpage\data\videos\3\cover.jpg
|
||
if not nas_path.startswith(NAS_VOLUME_PREFIX):
|
||
raise ValueError(f"NAS prefix 불일치: {nas_path}")
|
||
rel = nas_path[len(NAS_VOLUME_PREFIX):] # "docker/webpage/..."
|
||
return WINDOWS_DRIVE_ROOT + rel.replace("/", "\\")
|
||
```
|
||
|
||
### 4-4. 입력 검증
|
||
|
||
ffmpeg 호출 전:
|
||
- `cover_path` 변환된 Windows 경로의 파일 존재 확인 → 없으면 400 stage=input_validation
|
||
- `audio_path` 동일
|
||
- `output_path`의 부모 디렉토리 존재 확인 — 없으면 자동 생성
|
||
- `resolution` 정규식 `^\d{3,4}x\d{3,4}$` 검증 → 실패 시 400
|
||
|
||
### 4-5. ffmpeg 명령 (NVENC)
|
||
|
||
```python
|
||
def build_visualizer_cmd(cover_win, audio_win, out_win, w, h):
|
||
return [
|
||
"ffmpeg", "-y",
|
||
"-hwaccel", "cuda",
|
||
"-loop", "1", "-i", cover_win,
|
||
"-i", audio_win,
|
||
"-filter_complex",
|
||
f"[0:v]scale={w}:{h},format=yuv420p[bg];"
|
||
f"[1:a]showwaves=s={w}x200:mode=cline:colors=0xFF4444@0.8[wave];"
|
||
f"[bg][wave]overlay=0:({h}-200)[out]",
|
||
"-map", "[out]", "-map", "1:a",
|
||
"-c:v", "h264_nvenc",
|
||
"-preset", "p4", # quality preset (p1=fastest, p7=slowest/best)
|
||
"-rc", "vbr",
|
||
"-cq", "23", # quality (lower=better, 18-25 sane range)
|
||
"-b:v", "0", # let CQ control bitrate
|
||
"-pix_fmt", "yuv420p", # YouTube 호환
|
||
"-c:a", "aac", "-b:a", "192k",
|
||
"-shortest", out_win,
|
||
]
|
||
```
|
||
|
||
**주요 플래그 설명:**
|
||
- `-hwaccel cuda` — CUDA 사용
|
||
- `-c:v h264_nvenc` — NVIDIA NVENC H.264 인코더
|
||
- `-preset p4` — 품질·속도 균형 (5070 Ti 기준 1080p 영상 ~10–20s)
|
||
- `-rc vbr -cq 23 -b:v 0` — VBR + 일정 품질 (CQ 23 = ~CRF 23)
|
||
- `format=yuv420p` 명시 — NVENC가 가끔 yuv444 출력하는데 YouTube 호환 X
|
||
|
||
### 4-6. 타임아웃 + 출력 검증
|
||
|
||
- ffmpeg subprocess timeout: **180초** (NAS 측 HTTP timeout 200s 미만)
|
||
- 종료 후 출력 파일 존재 + 크기 > 1MB 검증 → 미달 시 stage=output_check 실패
|
||
- 종료 코드 0이지만 파일 비어있는 케이스 catch
|
||
|
||
### 4-7. 동시 처리
|
||
|
||
별도 큐 없음. 동시 호출 시 ffmpeg 프로세스 병렬 실행 — RTX 5070 Ti는 NVENC 세션 5개까지 지원.
|
||
|
||
단일 사용자 시나리오에서 동시 인코딩은 거의 발생 안 함. 발생해도 GPU 리소스 충분.
|
||
|
||
### 4-8. 헬스 체크 확장
|
||
|
||
기존 `GET /health`에 인코더 가용성 정보 추가:
|
||
```json
|
||
{
|
||
"ok": true,
|
||
"gpu": "NVIDIA GeForce RTX 5070 Ti",
|
||
"musicgen_loaded": true,
|
||
"ffmpeg_path": "C:/ffmpeg/bin/ffmpeg.exe",
|
||
"ffmpeg_nvenc": true
|
||
}
|
||
```
|
||
|
||
`ffmpeg_nvenc` 검증: 서버 시작 시 `ffmpeg -encoders | grep h264_nvenc` 한 번 실행 + 캐시.
|
||
|
||
---
|
||
|
||
## 5. NAS music-lab — `pipeline/video.py` 리팩토링
|
||
|
||
### 5-1. 환경변수 (필수)
|
||
|
||
```env
|
||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||
```
|
||
|
||
미설정 시: `pipeline/video.py`가 기동 시 명확한 에러로 실패 (ImportError 또는 RuntimeError).
|
||
|
||
### 5-2. `video.generate(...)` — 새 구현
|
||
|
||
```python
|
||
"""영상 비주얼 생성 — Windows GPU 서버 (NVENC) 호출."""
|
||
import os
|
||
import logging
|
||
import httpx
|
||
|
||
from . import storage
|
||
|
||
logger = logging.getLogger("music-lab.video")
|
||
|
||
ENCODER_URL = os.getenv("WINDOWS_VIDEO_ENCODER_URL", "")
|
||
ENCODER_TIMEOUT_S = 200 # Windows 서버 ffmpeg 180s + 마진
|
||
|
||
|
||
class VideoGenerationError(Exception):
|
||
pass
|
||
|
||
|
||
def generate(*, pipeline_id: int, audio_path: str, cover_path: str,
|
||
genre: str, duration_sec: int, resolution: str = "1920x1080",
|
||
style: str = "visualizer") -> dict:
|
||
"""원격 Windows 서버 호출. 다운/실패 시 즉시 예외."""
|
||
if not ENCODER_URL:
|
||
raise VideoGenerationError(
|
||
"WINDOWS_VIDEO_ENCODER_URL 미설정 — Windows 인코더 서버 주소 필요"
|
||
)
|
||
|
||
out_path = os.path.join(storage.pipeline_dir(pipeline_id), "video.mp4")
|
||
nas_audio = _container_to_nas(audio_path)
|
||
nas_cover = _container_to_nas(cover_path)
|
||
nas_output = _container_to_nas(out_path)
|
||
|
||
payload = {
|
||
"cover_path_nas": nas_cover,
|
||
"audio_path_nas": nas_audio,
|
||
"output_path_nas": nas_output,
|
||
"resolution": resolution,
|
||
"duration_sec": duration_sec,
|
||
"style": style,
|
||
}
|
||
|
||
logger.info("Windows 인코더 호출: %s → %s", audio_path, out_path)
|
||
try:
|
||
with httpx.Client(timeout=ENCODER_TIMEOUT_S) as client:
|
||
resp = client.post(f"{ENCODER_URL}/encode_video", json=payload)
|
||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
|
||
raise VideoGenerationError(f"Windows 인코더 연결 실패: {e}")
|
||
|
||
if resp.status_code != 200:
|
||
try:
|
||
detail = resp.json()
|
||
except Exception:
|
||
detail = {"error": resp.text[:300]}
|
||
raise VideoGenerationError(
|
||
f"Windows 인코더 오류 ({resp.status_code}): "
|
||
f"{detail.get('stage','?')} — {detail.get('error','?')}"
|
||
)
|
||
|
||
data = resp.json()
|
||
if not data.get("ok"):
|
||
raise VideoGenerationError(f"Windows 인코더 응답 ok=false: {data}")
|
||
|
||
return {
|
||
"url": storage.media_url(pipeline_id, "video.mp4"),
|
||
"used_fallback": False,
|
||
"duration_sec": duration_sec,
|
||
"encode_duration_ms": data.get("duration_ms"),
|
||
"encoder": data.get("encoder", "h264_nvenc"),
|
||
}
|
||
|
||
|
||
def _container_to_nas(container_path: str) -> str:
|
||
""" /app/data/videos/3/cover.jpg → /volume1/docker/webpage/data/videos/3/cover.jpg
|
||
/app/data/abc.mp3 → /volume1/docker/webpage/data/music/abc.mp3
|
||
"""
|
||
nas_videos_root = os.getenv("NAS_VIDEOS_ROOT", "/volume1/docker/webpage/data/videos")
|
||
nas_music_root = os.getenv("NAS_MUSIC_ROOT", "/volume1/docker/webpage/data/music")
|
||
if container_path.startswith("/app/data/videos/"):
|
||
return container_path.replace("/app/data/videos/", nas_videos_root + "/", 1)
|
||
if container_path.startswith("/app/data/"):
|
||
# 음악 파일 마운트가 /app/data 직접이라 서브디렉토리 없음 → music root에 직접
|
||
rel = container_path[len("/app/data/"):]
|
||
return nas_music_root + "/" + rel
|
||
return container_path # fallback (shouldn't happen)
|
||
```
|
||
|
||
### 5-3. 제거 항목
|
||
|
||
- `subprocess.run(...)` ffmpeg 호출 — 완전 제거
|
||
- `VIDEO_TIMEOUT_S = 600` — 사용 안 함 (`ENCODER_TIMEOUT_S`로 대체)
|
||
- `_build_visualizer_cmd` — 제거 (Windows 서버로 이전)
|
||
- `subprocess.TimeoutExpired` 예외 처리 — 제거
|
||
|
||
### 5-4. 환경변수 (NAS music-lab)
|
||
|
||
```yaml
|
||
# docker-compose.yml music-lab service environment
|
||
WINDOWS_VIDEO_ENCODER_URL: ${WINDOWS_VIDEO_ENCODER_URL}
|
||
NAS_VIDEOS_ROOT: ${NAS_VIDEOS_ROOT:-/volume1/docker/webpage/data/videos}
|
||
NAS_MUSIC_ROOT: ${NAS_MUSIC_ROOT:-/volume1/docker/webpage/data/music}
|
||
```
|
||
|
||
NAS `.env` 추가:
|
||
```env
|
||
WINDOWS_VIDEO_ENCODER_URL=http://192.168.45.59:8765
|
||
```
|
||
|
||
---
|
||
|
||
## 6. 에러 응답 매트릭스
|
||
|
||
| 상황 | NAS 측 결과 | 사용자 경험 |
|
||
|------|------------|-------------|
|
||
| Windows PC 꺼짐 | `VideoGenerationError("연결 실패")` | 진행 카드 `failed`, 텔레그램에 명확한 에러 |
|
||
| Windows ffmpeg 실패 | `VideoGenerationError("Windows 인코더 오류 500: ffmpeg — ...")` | 동일 |
|
||
| 입력 파일 NAS에 없음 | Windows가 400 응답 | "input_validation: cover not found" 메시지 |
|
||
| 출력 파일이 비어있음 | Windows가 500 응답 | "output_check: file empty" |
|
||
| 타임아웃 (180s+) | Windows가 504 응답 또는 connection close | "타임아웃 — GPU 부하 또는 입력 손상" |
|
||
| WINDOWS_VIDEO_ENCODER_URL 미설정 | 즉시 `VideoGenerationError` | 환경 미설정 안내 |
|
||
|
||
모두 pipeline state `failed`로 전이. 재생성 5회 한도 적용.
|
||
|
||
---
|
||
|
||
## 7. 헬스 모니터링
|
||
|
||
NAS music-lab 시작 시 1회 `GET {ENCODER_URL}/health` 호출 → 결과를 로그에 출력:
|
||
- 성공 + `ffmpeg_nvenc=true` → 인코더 사용 가능
|
||
- 실패 → 경고 로그 (구동은 계속, 호출 시점에 명확한 에러)
|
||
|
||
---
|
||
|
||
## 8. 테스트 전략
|
||
|
||
### 8-1. NAS music-lab 단위 테스트
|
||
|
||
`music-lab/tests/test_video_thumb.py` — 기존 ffmpeg 테스트를 HTTP mock 기반으로 교체:
|
||
|
||
```python
|
||
@respx.mock
|
||
def test_generate_video_calls_remote_encoder(monkeypatch):
|
||
monkeypatch.setenv("WINDOWS_VIDEO_ENCODER_URL", "http://192.168.45.59:8765")
|
||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||
return_value=Response(200, json={
|
||
"ok": True, "duration_ms": 12000,
|
||
"output_path_nas": "/volume1/...",
|
||
"encoder": "h264_nvenc", "preset": "p4"
|
||
})
|
||
)
|
||
out = video.generate(...)
|
||
assert out["url"].endswith("/video.mp4")
|
||
assert out["encode_duration_ms"] == 12000
|
||
|
||
|
||
@respx.mock
|
||
def test_generate_video_raises_on_connection_error(monkeypatch):
|
||
monkeypatch.setattr(video, "ENCODER_URL", "http://192.168.45.59:8765")
|
||
respx.post("http://192.168.45.59:8765/encode_video").mock(
|
||
side_effect=httpx.ConnectError("Connection refused")
|
||
)
|
||
with pytest.raises(video.VideoGenerationError) as exc:
|
||
video.generate(...)
|
||
assert "연결 실패" in str(exc.value)
|
||
|
||
|
||
def test_generate_video_no_url_configured(monkeypatch):
|
||
monkeypatch.setattr(video, "ENCODER_URL", "")
|
||
with pytest.raises(video.VideoGenerationError) as exc:
|
||
video.generate(...)
|
||
assert "WINDOWS_VIDEO_ENCODER_URL" in str(exc.value)
|
||
```
|
||
|
||
기존 `test_generate_video_calls_ffmpeg` / `test_generate_video_failure_marks_failed` 제거.
|
||
|
||
### 8-2. Windows `music_ai` 단위 테스트
|
||
|
||
`music_ai/tests/test_video_encoder.py` (신규):
|
||
|
||
```python
|
||
@patch("subprocess.run")
|
||
def test_translate_path():
|
||
assert video_encoder.translate_path("/volume1/docker/webpage/data/x.jpg") == r"Z:\docker\webpage\data\x.jpg"
|
||
|
||
def test_translate_path_rejects_bad_prefix():
|
||
with pytest.raises(ValueError):
|
||
video_encoder.translate_path("/something/else/x.jpg")
|
||
|
||
@patch("subprocess.run")
|
||
def test_encode_endpoint_success(mock_run, client, tmp_path):
|
||
# mock paths exist + ffmpeg succeeds
|
||
...
|
||
|
||
@patch("subprocess.run")
|
||
def test_encode_endpoint_input_missing(mock_run, client):
|
||
# 입력 파일 안 보이면 400
|
||
...
|
||
|
||
@patch("subprocess.run")
|
||
def test_encode_endpoint_ffmpeg_fails(mock_run, client, tmp_path):
|
||
# ffmpeg returncode=1 → 500 stage=ffmpeg
|
||
...
|
||
```
|
||
|
||
### 8-3. 통합 테스트
|
||
|
||
기존 `test_pipeline_flow.py`는 `cover.generate`를 mock하므로 영향 없음. video도 같이 mock — 변경 없음.
|
||
|
||
### 8-4. 수동 E2E
|
||
|
||
- [ ] Windows PC에서 `music_ai` 서버 시작 → `curl http://192.168.45.59:8765/health` → `ffmpeg_nvenc: true` 확인
|
||
- [ ] NAS에서 `curl -X POST http://192.168.45.59:8765/encode_video -d '{...}'` 직접 호출 → 200 응답 + Z:\에 video.mp4 생성 확인
|
||
- [ ] 진행 탭에서 새 파이프라인 시작 → 영상 단계가 10–20초 안에 완료 → 텔레그램 알림 도착
|
||
- [ ] Windows PC 꺼두고 새 파이프라인 시작 → 영상 단계 즉시 실패 → 진행 카드 failed + 명확한 에러 메시지
|
||
|
||
---
|
||
|
||
## 9. Windows PC 사전 준비
|
||
|
||
사용자가 Windows PC에서 1회 수행할 작업:
|
||
|
||
1. **ffmpeg + NVENC 빌드 설치**
|
||
- https://www.gyan.dev/ffmpeg/builds/ → "release full" 다운로드
|
||
- 압축 해제 → `C:\ffmpeg\bin\ffmpeg.exe`
|
||
- PATH 환경변수에 `C:\ffmpeg\bin` 추가
|
||
- 검증: `ffmpeg -version` 동작, `ffmpeg -encoders | findstr h264_nvenc` 결과 출력
|
||
|
||
2. **NVIDIA 드라이버** — 이미 MusicGen용으로 설치돼 있음
|
||
|
||
3. **SMB 마운트 확인** — `Z:\docker\webpage\` 접근 가능해야 함
|
||
|
||
4. **방화벽** — 포트 8765 LAN 인바운드 허용 (이미 MusicGen용으로 설정돼 있음)
|
||
|
||
5. **`music_ai/.env`에 추가**:
|
||
```env
|
||
NAS_VOLUME_PREFIX=/volume1/
|
||
WINDOWS_DRIVE_ROOT=Z:\
|
||
FFMPEG_PATH=C:\ffmpeg\bin\ffmpeg.exe
|
||
```
|
||
|
||
6. **`music_ai/start.bat` 재시작** — 새 endpoint 활성화
|
||
|
||
---
|
||
|
||
## 10. 산출물
|
||
|
||
| 영역 | 파일 |
|
||
|------|------|
|
||
| Windows | `music_ai/video_encoder.py` (신규) |
|
||
| Windows | `music_ai/server.py` (수정 — `/encode_video` endpoint 등록, `/health` 확장) |
|
||
| Windows | `music_ai/.env.example` (수정 — 새 변수 문서화) |
|
||
| Windows | `music_ai/tests/test_video_encoder.py` (신규) |
|
||
| NAS | `music-lab/app/pipeline/video.py` (재작성) |
|
||
| NAS | `music-lab/tests/test_video_thumb.py` (수정 — HTTP mock 기반) |
|
||
| Infra | `web-backend/docker-compose.yml` (env 3개 추가) |
|
||
| Infra | NAS `.env` (사용자 수동, 1개 추가) |
|
||
|
||
---
|
||
|
||
## 11. 후속
|
||
|
||
- (P3) 영상 인코딩 진행률 실시간 보고 — Windows에서 ffmpeg progress 파싱 후 진행 탭 카드에 표시 (현재는 단순 "running")
|
||
- (P3) Windows 서버 다중 큐 — 동시 요청 시 GPU 부하 추적 + 큐잉
|
||
- (P4) 인코딩 옵션을 youtube_setup `visual_defaults`로 추가 — preset(p1~p7), CQ, 해상도 옵션 노출
|
||
- (P4) Shorts 전용 1080×1920 인코딩 프로파일
|
||
|
||
---
|